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..3576cbf --- /dev/null +++ b/README.md @@ -0,0 +1,415 @@ +# didcel + +JavaScript library for creating and managing Decentralized Identifiers (DIDs) +using the `did:cel` method. DIDs are secured by a Cryptographic Event Log (CEL) +— a hash-linked chain of witnessed events — with no dependency on blockchains or +centralized registries. + +## Installation + +```bash +npm install +``` + +Requires Node.js v24+. + +## API + +All public functions are exported from the package entry point: + +```js +import { + // DID document operations + create, addVm, createEvent, deriveHeartbeatKeyPair, + sha3256Multibase, setHeartbeatFrequency, + // CEL operations + addEvent, getPreviousEventHash, witness, + read, loadFromFile, saveToFile, + // Secret key storage + saveSecrets, loadSecrets, + // Utilities + getObjectByIdSuffix, deleteObjectByIdSuffix, prettyPrintCel, + // Low-level witness HTTP client + witnessService +} from 'didcel'; +``` + +--- + +### `create([options])` → `{keyPair, heartbeatSecret, didDocument, cryptographicEventLog}` + +Creates a new `did:cel` DID. Returns the assertion method key pair, a 16-byte +heartbeat master secret, the signed DID document, and a CEL pre-loaded with the +create event. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `options.curve` | string | `'P-256'` | Elliptic curve for key generation. | +| `options.heartbeatFrequency` | string | `'P1M'` | Required heartbeat interval (ISO 8601 duration). | + +```js +const {keyPair, heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); +// didDocument.id === 'did:cel:z...' +``` + +--- + +### `deriveHeartbeatKeyPair(masterSecret, index)` → `Promise` + +Derives the heartbeat key pair at a given index from the master secret returned +by `create()`. The key at index 0 corresponds to the hash already in +`didDocument.heartbeat`. Every CEL operation (except deactivate) must be signed +with the currently active heartbeat key and must rotate to the next key. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `masterSecret` | Buffer | 16-byte heartbeat master secret from `create()`. | +| `index` | number | Key index. Start at 0; increment after each rotation. | + +```js +const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); +const hbKey1 = await deriveHeartbeatKeyPair(heartbeatSecret, 1); +``` + +--- + +### `sha3256Multibase(input)` → `Promise` + +Returns the base58btc-encoded SHA3-256 multihash of `input` (a `z`-prefixed +string). Use this to compute the heartbeat hash stored in `didDocument.heartbeat`: + +```js +const exported = await hbKey1.export({publicKey: true, includeContext: false}); +const nextHash = await sha3256Multibase(`did:key:${exported.publicKeyMultibase}`); +``` + +--- + +### `createEvent({type, data, signingKeyPair, previousEventHash})` → `Promise` + +Creates and signs a CEL event. All events must be signed by the **currently +active heartbeat key** (from `deriveHeartbeatKeyPair`). Every event except +`deactivate` must rotate the heartbeat key by including the next heartbeat hash +in `data`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `type` | string | `'update'`, `'heartbeat'`, or `'deactivate'`. | +| `data` | object\|undefined | DID document for `update`; `{heartbeat: [""]}` for `heartbeat`; `undefined` for `deactivate`. | +| `signingKeyPair` | KeyPair | The active heartbeat key pair. | +| `previousEventHash` | string | Hash of the previous event from `getPreviousEventHash()`. | + +Returns the signed event object directly (not wrapped in `{event}`). + +```js +// update: full DID document with rotated heartbeat hash +const updateEvent = await createEvent({ + type: 'update', + data: {...updatedDoc, heartbeat: [nextHash]}, + signingKeyPair: hbKey0, + previousEventHash +}); + +// heartbeat: partial object with only the new heartbeat hash +const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [nextHash]}, + signingKeyPair: hbKey0, + previousEventHash +}); + +// deactivate: no data, no rotation needed +const deactivateEvent = await createEvent({ + type: 'deactivate', + signingKeyPair: hbKey0, + previousEventHash +}); +``` + +--- + +### `getPreviousEventHash({cel})` → `Promise` + +Returns the hash of the most recent event in the CEL. Call this before +`createEvent()` and pass the result as `previousEventHash` so the hash is +covered by the operation proof. + +```js +const previousEventHash = await getPreviousEventHash({cel: cryptographicEventLog}); +``` + +--- + +### `addEvent({cel, event})` → `Promise` + +Appends a pre-signed event to the CEL. Throws `MALFORMED_CEL_ERROR` if the log +is empty or already deactivated. + +```js +await addEvent({cel: cryptographicEventLog, event: updateEvent}); +``` + +--- + +### `witness({cel, witnesses})` → `Promise` + +Obtains witness attestations for the most recent event. Call after every +`addEvent()`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `cel` | object | The CEL. | +| `witnesses` | string[] | Witness service URLs. | + +```js +await witness({ + cel: cryptographicEventLog, + witnesses: ['https://witness.example/witnesses/v1'] +}); +``` + +--- + +### `addVm({didDocument, verificationRelationship, [curve]})` → `{keyPair, didDocument}` + +Generates a new key pair and adds it to the specified verification relationship. +The returned document has its proof removed and must be re-signed via +`createEvent` before appending to the CEL. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `didDocument` | object | The current DID document. | +| `verificationRelationship` | string | `'authentication'`, `'assertionMethod'`, `'capabilityInvocation'`, `'capabilityDelegation'`, or `'keyAgreement'`. | +| `curve` | string | Elliptic curve. Default: `'P-256'`. | + +```js +const {keyPair: authKeyPair, didDocument: updatedDoc} = await addVm({ + didDocument, + verificationRelationship: 'authentication' +}); +``` + +--- + +### `setHeartbeatFrequency({didDocument, heartbeatFrequency})` → `{didDocument}` + +Updates `heartbeatFrequency` on a DID document and removes the proof. The +document must be re-signed via `createEvent` before appending to the CEL. + +```js +const {didDocument: updatedDoc} = setHeartbeatFrequency({ + didDocument, + heartbeatFrequency: 'P1W' +}); +``` + +--- + +### `saveToFile({filename, cel})` + +Saves a CEL to a gzip-compressed file. + +```js +saveToFile({filename: './logs/my-did.cel', cel: cryptographicEventLog}); +``` + +--- + +### `loadFromFile({filename, [trustedWitnesses], [versionTime]})` → `Promise<{cel, valid, errors, didDocument}>` + +Loads and validates a CEL file. Returns `valid: false` and a non-empty `errors` +array if any check fails (identifier integrity, hash chain, operation and witness +proof signatures, timestamp deviation, heartbeat rotation, heartbeat frequency). + +| Parameter | Type | Description | +|-----------|------|-------------| +| `filename` | string | Path to the `.cel` file. | +| `trustedWitnesses` | `{id, validFrom, validUntil}[]` | Witnesses to verify. Only proofs from listed witnesses within their validity window are checked. | +| `versionTime` | string | ISO datetime for historical DID resolution. Entries witnessed after this time are excluded. | + +```js +const {valid, errors, didDocument} = await loadFromFile({ + filename: './logs/my-did.cel', + trustedWitnesses: [{ + id: 'did:key:z...', + validFrom: '2024-01-01T00:00:00Z', + validUntil: '2099-01-01T00:00:00Z' + }] +}); +``` + +--- + +### `read({cel, [trustedWitnesses], [versionTime]})` → `Promise<{cel, valid, errors, didDocument}>` + +Same as `loadFromFile` but accepts an already-parsed CEL object. + +--- + +### `saveSecrets({didIdentifier, secretKeys, password, secretsDir})` + +Encrypts private keys with AES-256-GCM and saves them to +`{secretsDir}/{didIdentifier}.yaml`. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `didIdentifier` | string | The method-specific ID (everything after `did:cel:`). | +| `secretKeys` | object | Keys by relationship. Verification relationships hold arrays of key pairs; `heartbeat` holds the 16-byte master secret Buffer. | +| `password` | string | Encryption password. | +| `secretsDir` | string | Directory to write into. | + +```js +await saveSecrets({ + didIdentifier, + secretKeys: { + assertionMethod: [keyPair], + authentication: [], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [], + heartbeat: heartbeatSecret + }, + password, + secretsDir +}); +``` + +--- + +### `loadSecrets({didIdentifier, password, secretsDir})` → `Promise` + +Decrypts and returns private keys. `secretKeys.heartbeat` is a 16-byte Buffer +(the master secret); each other field is an array of key pair objects. + +```js +const secretKeys = await loadSecrets({didIdentifier, password, secretsDir}); +const hbKey = await deriveHeartbeatKeyPair(secretKeys.heartbeat, currentIndex); +``` + +--- + +## Heartbeat Key Rotation + +Every event signed after `create` uses the **heartbeat key** derived at the +current rotation index. Each event (except `deactivate`) must advance the index +by including the hash of the *next* key in the event data, and must not reuse a +key whose hash is still in `didDocument.heartbeat`. + +``` +index 0 → signs create (hash of key 0 placed in didDocument.heartbeat at create time) +index 0 → signs event 1 (data includes hash of key 1; hash of key 0 is removed) +index 1 → signs event 2 (data includes hash of key 2; hash of key 1 is removed) +... +index N → signs deactivate (no rotation needed) +``` + +--- + +## Typical Workflow + +```js +import {join} from 'node:path'; +import { + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, + getPreviousEventHash, loadFromFile, loadSecrets, saveSecrets, + saveToFile, sha3256Multibase, witness +} from 'didcel'; + +const WITNESSES = ['https://witness.example/witnesses/v1']; +const LOGS_DIR = './logs'; +const SECRETS_DIR = './secrets'; +const PASSWORD = process.env.DID_PASSWORD; + +// Helper: hash of heartbeat key at a given index +async function heartbeatHash(secret, index) { + const kp = await deriveHeartbeatKeyPair(secret, index); + const exp = await kp.export({publicKey: true, includeContext: false}); + return sha3256Multibase(`did:key:${exp.publicKeyMultibase}`); +} + +// 1. Create a new DID +const {keyPair, heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); +await witness({cel: cryptographicEventLog, witnesses: WITNESSES}); + +// 2. Update: add authentication key, rotate heartbeat key 0 → 1 +const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); +const {didDocument: updatedDoc} = + await addVm({didDocument, verificationRelationship: 'authentication'}); +updatedDoc.heartbeat = [await heartbeatHash(heartbeatSecret, 1)]; + +const updateEvent = await createEvent({ + type: 'update', + data: updatedDoc, + signingKeyPair: hbKey0, + previousEventHash: await getPreviousEventHash({cel: cryptographicEventLog}) +}); +await addEvent({cel: cryptographicEventLog, event: updateEvent}); +await witness({cel: cryptographicEventLog, witnesses: WITNESSES}); + +// 3. Save the CEL and encrypted secrets +const didIdentifier = didDocument.id.replace('did:cel:', ''); +saveToFile({ + filename: join(LOGS_DIR, `${didIdentifier}.cel`), + cel: cryptographicEventLog +}); +await saveSecrets({ + didIdentifier, + secretKeys: { + assertionMethod: [keyPair], + authentication: [], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [], + heartbeat: heartbeatSecret + }, + password: PASSWORD, + secretsDir: SECRETS_DIR +}); + +// 4. Later: load and verify +const {valid, errors, didDocument: resolved} = await loadFromFile({ + filename: join(LOGS_DIR, `${didIdentifier}.cel`), + trustedWitnesses: [{ + id: 'did:key:z...', + validFrom: '2024-01-01T00:00:00Z', + validUntil: '2099-01-01T00:00:00Z' + }] +}); +``` + +--- + +## Architecture + +- **Self-certifying identifiers:** The DID is derived from a hash of the initial + DID document, so its integrity can be verified without any external registry. +- **Cryptographic Event Log (CEL):** Each operation (`create`, `update`, + `heartbeat`, `deactivate`) is signed with the active heartbeat key and + hash-linked to the previous event. Non-create events carry a `previousEventHash` + that is set before signing, so the hash chain is covered by the proof. +- **Blind witnesses:** Witnesses receive only a hash of each event, never the DID + document, and return a `DataIntegrityProof` for temporal anchoring. +- **Heartbeat keys:** A 16-byte master secret is stored; individual keys are + derived on demand. Each key is one-time-use — its hash is rotated out of + `didDocument.heartbeat` when it signs an event. The `deactivate` event is the + only exception: no rotation is required. +- **Encrypted secrets:** Private keys are encrypted with AES-256-GCM (scrypt key + derivation) and stored as YAML. + +## File Structure + +| File | Contents | +|------|----------| +| `lib/index.js` | Package entry point; all public exports | +| `lib/didcel.js` | `create`, `addVm`, `createEvent`, `setHeartbeatFrequency`, `deriveHeartbeatKeyPair` | +| `lib/cel.js` | `addEvent`, `getPreviousEventHash`, `witness`, `read`, `loadFromFile`, `saveToFile` | +| `lib/secrets.js` | `saveSecrets`, `loadSecrets` | +| `lib/witness.js` | HTTP client for witness services | +| `lib/utils.js` | `sha3256Multibase`, `prettyPrintCel`, suffix-based document lookup | +| `lib/validate.js` | AJV JSON Schema validation for DID documents and CELs | + +## License + +BSD-3-Clause diff --git a/didcel b/didcel deleted file mode 100755 index 6b2b591..0000000 --- a/didcel +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env node -import { Argument, Command, CommanderError } from 'commander'; -import cel from './lib/cel.js'; -import didcel from './lib/didcel.js'; -import promptSync from 'prompt-sync'; -import {writeFileSync} from 'fs'; -import {createJsonldPrettyPrinter, deleteObjectByIdSuffix, - getObjectByIdSuffix} from './lib/utils.js'; - -// 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(); - -// create the JSON-LD pretty printer -const jsonldPretty = createJsonldPrettyPrinter({ - preferOrder: ['@context', '@id', '@type', 'id', 'type', 'cryptosuite', - '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 - const prompt = promptSync(); - const repl = new Command(); - let cryptographicEventLog; - let didDocument; - let secretKeys = { - authentication: [], - assertionMethod: [], - capabilityInvocation: [], - capabilityDelegation: [], - keyAgreement: [] - }; - - repl.name('command') - .usage('[options]') - .exitOverride(); - - repl.command('help') - .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(async () => { - let result = await didcel.create({curve: 'P-256'}); - didDocument = result.didDocument; - secretKeys.assertionMethod = [result.keyPair]; - cryptographicEventLog = cel.create({data: didDocument}); - console.log(`create successful: ${didDocument.id}`); - }); - - 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)) - .addArgument(new Argument('', 'the type of property to add') - .choices(['eddsa', 'ecdsa', 'bbs', 'FileService'])) - .action(async (property, type) => { - if(property !== 'service' && type === 'ecdsa') { - let result = await didcel.addVm( - {didDocument, verificationRelationship: property, curve: 'P-256'}); - didDocument = result.didDocument; - secretKeys[property].push(result.keyPair); - console.log(`add: new verification method for ${property}`); - } - }); - - 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) => { - console.log(didDocument.id); - - // 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); - } - } - }); - - 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) => { - 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') - .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) { - 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') - .description('Update the cryptographic event log with the latest DID document') - .action(async () => { - didDocument = (await didcel.updateProof({didDocument, - assertionMethod: secretKeys.assertionMethod[0]})).didDocument; - 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('witness: proofs complete'); - }); - - 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) => { - const celFilename = filename || 'did.cel'; - writeFileSync(celFilename, JSON.stringify(cryptographicEventLog, jsonldPretty, 2)); - console.error(`Wrote to ${celFilename}`); - }); - - 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 - if(commands && commands.length > 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); - } catch(err) { - if(!(err instanceof CommanderError)) { - throw err; - } - } - }; - } - - // if no command line commands were given, run the repl - let command = ''; - do { - const args = prompt('did:cel> ').split(' '); - command = args[0]; - - try { - const commanderArgs = [command, command].concat(args); - await repl.parseAsync(commanderArgs); - } catch(err) { - // don't automatically exit from the Commander CLI - if(!(err instanceof CommanderError)) { - throw err; - } - } - } while(command != 'quit'); -} - -// Run the repl -await repl({ - commands: options.command -}); diff --git a/lib/cel.js b/lib/cel.js index 3065d54..f4c5374 100644 --- a/lib/cel.js +++ b/lib/cel.js @@ -1,95 +1,659 @@ +/** + * @file Cryptographic Event Log (CEL) read, write, and validation. + */ + 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 * as witnessService from './witness.js'; +import {gunzipSync, gzipSync} from 'node:zlib'; +import {readFileSync, writeFileSync} from 'node:fs'; +import {sha3256Multibase, VERIFICATION_RELATIONSHIPS} from './utils.js'; +import {assertValidCel} from './validate.js'; +import {decode as base58Decode} from 'base58-universal'; 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 crypto from 'node:crypto'; +import moment from 'moment'; 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: [{ - event: { - operation: { - type: 'create', - data - } - } - }] - }; +/** + * Creates a new CEL containing a single create event. + * + * @param {object} options - Configuration options. + * @param {object} options.event - The signed create event. + * @returns {object} CEL object: `{log: [{event}]}`. + */ +export function create({event}) { + return {log: [{event}]}; +} + +/** + * Sends the last event in a CEL to each witness and attaches their proofs. + * The event is hashed (SHA3-256 multihash) and the digest is sent to each + * witness URL; each witness returns a DataIntegrityProof. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The CEL whose last event will be witnessed. + * @param {Array} options.witnesses - Witness service URLs. + * @param {boolean} [options.allowSelfSigned=false] - Passed through to the + * HTTP witness client; see `witnessService.witness()`. + * @returns {Promise} The proof array attached to the last log entry. + */ +export async function witness({cel, witnesses, allowSelfSigned}) { + if(!cel.log || cel.log.length === 0) { + const err = new Error( + 'Cannot witness an empty CEL log - use cel.create() first'); + err.name = 'MALFORMED_CEL_ERROR'; + throw err; + } + if(!Array.isArray(witnesses) || witnesses.length === 0) { + throw new Error('No witnesses provided.'); + } + + const logEntry = cel.log[cel.log.length - 1]; + // hash the bare event (not the wrapper) — this is what the spec requires + const digestMultibase = sha3256Multibase(canonicalize(logEntry.event)); + + let proofs; + try { + proofs = await Promise.all(witnesses.map( + witnessUrl => witnessService.witness( + {digestMultibase, witnessUrl, allowSelfSigned}))); + } catch(e) { + const err = new Error(`Witnessing failed: ${e.message}`); + err.name = 'WITNESSING_ERROR'; + throw err; + } + + logEntry.proof = proofs; + return logEntry.proof; +} - // set a previous log if there is one - if(options?.previousLog) { - log.previousLog = options.previousLog; +/** + * Returns the SHA3-256 multibase hash of the last event in a CEL. + * Callers include this value as `previousEventHash` on the next event + * before signing, so the hash chain is covered by the operation proof. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The CEL. + * @returns {string|undefined} Multibase hash, or undefined if empty. + */ +export function getPreviousEventHash({cel}) { + if(cel.log.length === 0) { + return undefined; } + const lastEvent = cel.log[cel.log.length - 1].event; + return sha3256Multibase(canonicalize(lastEvent)); +} - return log; +/** + * Appends a signed event to a CEL, extending the hash-linked chain. + * `previousEventHash` must already be set on the event and covered by its + * proof before calling this function. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The CEL to append to. + * @param {object} options.event - The signed event to append. + * @returns {Promise} The updated CEL. + */ +export async function addEvent({cel, event}) { + if(!cel.log || cel.log.length === 0) { + const err = new Error( + 'Cannot add event to an empty CEL log - use cel.create() first'); + err.name = 'MALFORMED_CEL_ERROR'; + throw err; + } + const isDeactivated = cel.log.some( + entry => entry.event?.operation?.type === 'deactivate'); + if(isDeactivated) { + const err = new Error( + 'Cannot add event to a deactivated CEL - deactivation is terminal'); + err.name = 'MALFORMED_CEL_ERROR'; + throw err; + } + cel.log.push({event}); + assertValidCel({cel}); + return cel; } -export async function witness({cel, options}) { - const proofs = []; - const event = cel.log[cel.log.length-1]; +/** + * Reads and fully validates a CEL. Returns `{cel, errors, valid, didDocument}`. + * + * Checks performed for each log entry, in order: + * 1. Structure — JSON Schema via assertValidCel(). + * 2. Self-certifying DID — `did:cel:` must equal SHA3-256(JCS(initial doc)). + * 3. Hash chain — `previousEventHash` must match the prior event's digest. + * 4. Operation proof — ecdsa-jcs-2019, keys from the *prior* DID document. + * 5. Heartbeat rotation — signing key hash must be swapped out after use. + * 6. Witness proofs — signature + ≤5 min clock deviation per trusted witness. + * 7. Heartbeat frequency — gap between consecutive witnessed entries. + * + * Witness errors are deferred one iteration so that a backdated timestamp + * triggers a heartbeatFrequency violation on the next entry (the root cause) + * rather than surfacing as a signature error on the backdated entry. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The parsed CEL. + * @param {Array} [options.trustedWitnesses=[]] - Trusted witness + * entries: `{id, validFrom, validUntil}`. Proofs from unknown witnesses or + * outside the validity window are ignored. + * @param {string|null} [options.versionTime=null] - ISO datetime. When set, + * entries whose earliest trusted witness timestamp exceeds this value are + * skipped, enabling historical DID document resolution. + * @returns {Promise} `{cel, errors, valid, didDocument}`. + */ +export async function read({cel, trustedWitnesses = [], versionTime = null}) { + try { + assertValidCel({cel}); + } catch(e) { + return {cel, errors: [e.message], valid: false, didDocument: null}; + } + + if(cel.log.length === 0) { + return {cel, errors: ['CEL log is empty'], valid: false, didDocument: null}; + } + + const idErr = _verifySelfCertifyingId({firstEvent: cel.log[0].event}); + if(idErr) { + return {cel, errors: [idErr], valid: false, didDocument: null}; + } + + let currentDidDocument = null; + let prevEntryWitnessTime = null; // latest witness ms from the prior entry + let deactivated = false; + // Witness errors are held here for one iteration. If the next entry's + // heartbeatFrequency check fires, it supersedes them as the root cause. + let pendingWitnessErrors = 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 ?? []; + + if(deactivated) { + return { + cel, + errors: [`entry ${i}: operation after deactivation is not permitted`], + valid: false, + didDocument: null + }; + } + + const trustedWitnessProofs = witnessProofs.filter( + wp => _isTrustedWitnessProof({wp, trustedWitnesses})); - // 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'; + // versionTime cutoff — must run before any state mutation so a skipped + // entry can never contaminate the verified document returned to the caller + if(versionTime !== null && trustedWitnessProofs.length > 0) { + const versionTimeMs = new Date(versionTime).getTime(); + const earliestWitnessTime = Math.min( + ...trustedWitnessProofs.map(wp => new Date(wp.created).getTime())); + if(earliestWitnessTime > versionTimeMs) { + break; + } + } + + if(i > 0) { + const chainErr = _verifyHashChain({cel, i, event}); + if(chainErr) { + return {cel, errors: [chainErr], valid: false, didDocument: null}; + } + } + + // Snapshot prevDidDocument before advancing — the heartbeatFrequency check + // needs the frequency in effect for the gap leading into this entry, not + // any new value this entry's update might introduce. + const prevDidDocument = currentDidDocument; + currentDidDocument = _advanceDidDocument({currentDidDocument, event}); + + if(event.operation?.type === 'deactivate') { + deactivated = true; + } + + // Look up keys in the *prior* document to prevent circular + // key-introduction: an attacker must not add a key via an update and + // simultaneously use that key to sign the update. The create event + // (i === 0) has no prior state; its integrity is pinned by the ID. + const verifyDidDocument = i === 0 ? currentDidDocument : prevDidDocument; + const opProofErr = await _verifyOperationProofEntry( + {i, event, opProof, verifyDidDocument, prevDidDocument}); + if(opProofErr) { + return {cel, errors: [opProofErr], valid: false, didDocument: null}; + } + + const rotationErr = _checkHeartbeatRotation( + {i, event, opProof, prevDidDocument, currentDidDocument}); + if(rotationErr) { + return {cel, errors: [rotationErr], valid: false, didDocument: null}; + } + + const {errors: witnessErrors, entryWitnessTime} = + await _verifyWitnessProofsEntry( + {i, logEntry, trustedWitnessProofs, opProof}); + + // A frequency violation supersedes pending witness errors from the prior + // entry — a backdated timestamp is the root cause of both. + const freqErr = _checkHeartbeatFrequency( + {i, prevEntryWitnessTime, entryWitnessTime, prevDidDocument, + currentDidDocument}); + if(freqErr) { + return {cel, errors: [freqErr], valid: false, didDocument: null}; + } + + if(pendingWitnessErrors) { + return { + cel, errors: pendingWitnessErrors, valid: false, didDocument: null}; + } + + if(entryWitnessTime !== null) { + prevEntryWitnessTime = entryWitnessTime; + } + pendingWitnessErrors = witnessErrors.length > 0 ? witnessErrors : null; + } + + if(pendingWitnessErrors) { + return {cel, errors: pendingWitnessErrors, valid: false, didDocument: null}; + } + + return {cel, errors: [], valid: true, didDocument: currentDidDocument}; +} + +/** + * Checks that the DID in the first event equals + * `did:cel:` + SHA3-256(JCS(doc without `id` and VM `controller` fields)). + * Those fields are stripped to reconstruct the pre-hash document state. + * + * @param {object} options - Configuration options. + * @param {object} options.firstEvent - The first log entry's event. + * @returns {string|null} Error message, or null if valid. + */ +function _verifySelfCertifyingId({firstEvent}) { + const firstDidDocument = structuredClone(firstEvent?.operation?.data ?? {}); + delete firstDidDocument.id; + for(const rel of VERIFICATION_RELATIONSHIPS) { + if(Array.isArray(firstDidDocument[rel])) { + for(const vm of firstDidDocument[rel]) { + if(typeof vm === 'object') { + delete vm.controller; + } + } + } } + const expectedId = + 'did:cel:' + sha3256Multibase(canonicalize(firstDidDocument)); + const claimedId = firstEvent?.operation?.data?.id; + if(claimedId !== expectedId) { + return `DID identifier mismatch: claimed "${claimedId}", ` + + `expected "${expectedId}"`; + } + return null; +} - // 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); +/** + * Checks that `event.previousEventHash` equals the SHA3-256 hash of the + * prior event. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The full CEL. + * @param {number} options.i - Current entry index (must be > 0). + * @param {object} options.event - The current event. + * @returns {string|null} Error message, or null if valid. + */ +function _verifyHashChain({cel, i, event}) { + const computed = sha3256Multibase(canonicalize(cel.log[i - 1].event)); + if(computed !== event.previousEventHash) { + return `entry ${i}: previousEventHash mismatch ` + + `(expected ${computed}, got ${event.previousEventHash})`; } + return null; +} - return proofs; +/** + * Returns the DID document state after applying an event's operation. + * Heartbeat merges only the `heartbeat` array into the existing document; + * all other operations replace the document wholesale. + * + * @param {object} options - Configuration options. + * @param {object|null} options.currentDidDocument - Current document state. + * @param {object} options.event - The event being applied. + * @returns {object|null} Updated document state. + */ +function _advanceDidDocument({currentDidDocument, event}) { + if(!event.operation?.data) { + return currentDidDocument; + } + if(event.operation.type === 'heartbeat') { + return {...currentDidDocument, heartbeat: event.operation.data.heartbeat}; + } + return event.operation.data; } -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); +/** + * Verifies the operation proof for a single log entry. + * Keys are looked up in `verifyDidDocument` (the previously verified state) + * to prevent circular key-introduction attacks. + * + * @param {object} options - Configuration options. + * @param {number} options.i - Log entry index. + * @param {object} options.event - The event object. + * @param {object} options.opProof - The operation proof (may be undefined). + * @param {object} options.verifyDidDocument - Document to look up signing keys + * in. + * @param {object|null} options.prevDidDocument - Document state before this + * entry. + * @returns {Promise} Error message, or null if valid. + */ +async function _verifyOperationProofEntry( + {i, event, opProof, verifyDidDocument, prevDidDocument}) { + if(!opProof) { + return `entry ${i}: operation proof is missing`; + } + try { + const verified = await _verifyOperationProof( + {event, opProof, prevDidDocument: prevDidDocument ?? verifyDidDocument}); + if(!verified) { + return `entry ${i}: operation proof invalid`; + } + } catch(e) { + return `entry ${i}: operation proof error: ${e.message}`; } + return null; +} + +/** + * Verifies all trusted witness proofs for a single log entry. + * Also checks that each witness timestamp is within 5 min of the operation + * proof timestamp. Returns all errors and the latest witness timestamp seen. + * + * @param {object} options - Configuration options. + * @param {number} options.i - Log entry index. + * @param {object} options.logEntry - The full log entry `{event, proof[]}`. + * @param {Array} options.trustedWitnessProofs - Pre-filtered trusted proofs. + * @param {object} options.opProof - The operation proof (for timestamp check). + * @returns {Promise<{errors: string[], entryWitnessTime: number|null}>} + * All errors found and the latest witness timestamp seen. + */ +async function _verifyWitnessProofsEntry( + {i, logEntry, trustedWitnessProofs, opProof}) { + const errors = []; + const opTime = opProof?.created ? + new Date(opProof.created).getTime() : null; + let entryWitnessTime = null; - // push event to end of log - cel.log.push({ - event: { - previousEvent, - operation: { - type: 'update', - data + for(let j = 0; j < trustedWitnessProofs.length; j++) { + const witnessProof = trustedWitnessProofs[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}`); + } + + if(!witnessProof.created) { + errors.push( + `entry ${i} witness ${j}: missing required created timestamp`); + } else { + const wTime = new Date(witnessProof.created).getTime(); + if(entryWitnessTime === null || wTime > entryWitnessTime) { + entryWitnessTime = wTime; + } + if(opTime !== null) { + 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 {errors, entryWitnessTime}; +} + +/** + * Checks that the heartbeat key used to sign this entry has been rotated: + * its hash must be removed from `heartbeat[]` and a new one added. + * Skipped for the create event (no predecessor) and deactivate (terminal). + * + * @param {object} options - Configuration options. + * @param {number} options.i - Log entry index. + * @param {object} options.event - The event object. + * @param {object} options.opProof - The operation proof. + * @param {object|null} options.prevDidDocument - Document state before this + * entry. + * @param {object|null} options.currentDidDocument - Document state after this + * entry. + * @returns {string|null} Error message, or null if valid. + */ +function _checkHeartbeatRotation( + {i, event, opProof, prevDidDocument, currentDidDocument}) { + if(!opProof || !currentDidDocument || i === 0 || + event.operation?.type === 'deactivate') { + return null; + } + const vmRef = opProof.verificationMethod; + if(!vmRef?.startsWith('did:key:')) { + return null; + } + const didKeyId = vmRef.split('#')[0]; + const usedHash = sha3256Multibase(didKeyId); + const prevHeartbeat = prevDidDocument?.heartbeat ?? []; + const newHeartbeat = currentDidDocument?.heartbeat ?? []; + if(prevHeartbeat.includes(usedHash)) { + if(newHeartbeat.includes(usedHash)) { + return `entry ${i}: heartbeat key used without rotating its hash - ` + + `${usedHash} must be removed from heartbeat[]`; + } + if(newHeartbeat.length < prevHeartbeat.length) { + return `entry ${i}: heartbeat key rotation must add a new heartbeat ` + + `hash to replace the consumed one`; + } + } + return null; +} + +/** + * Checks that the elapsed time between consecutive witnessed entries does not + * exceed the `heartbeatFrequency` in effect for the gap. Uses the frequency + * from `prevDidDocument` so a tightened value introduced by this entry does + * not apply retroactively to the gap that preceded it. + * + * @param {object} options - Configuration options. + * @param {number} options.i - Log entry index. + * @param {number|null} options.prevEntryWitnessTime - Latest witness ms from + * the prior entry. + * @param {number|null} options.entryWitnessTime - Latest witness ms for this + * entry. + * @param {object|null} options.prevDidDocument - Document state before this + * entry. + * @param {object|null} options.currentDidDocument - Document state after this + * entry. + * @returns {string|null} Error message, or null if valid. + */ +function _checkHeartbeatFrequency( + {i, prevEntryWitnessTime, entryWitnessTime, prevDidDocument, + currentDidDocument}) { + if(i === 0 || prevEntryWitnessTime === null || entryWitnessTime === null) { + return null; + } + const heartbeatFrequency = + (prevDidDocument ?? currentDidDocument)?.heartbeatFrequency ?? 'P1M'; + const freq = moment.duration(heartbeatFrequency); + const elapsed = entryWitnessTime - prevEntryWitnessTime; + if(elapsed > freq.asMilliseconds()) { + const elapsedDuration = moment.duration(elapsed).humanize(); + return `entry ${i}: heartbeatFrequency violation - ` + + `${elapsedDuration} elapsed since previous witnessed event ` + + `exceeds ${heartbeatFrequency}`; + } + return null; +} + +/** + * Returns true if `wp` was issued by a trusted witness and its `created` + * timestamp falls within that witness's `validFrom`/`validUntil` window. + * + * @param {object} options - Configuration options. + * @param {object} options.wp - The witness proof to evaluate. + * @param {Array} options.trustedWitnesses - Trusted witness entries. + * @returns {boolean} True if the proof is from a trusted witness in its + * validity window. + */ +function _isTrustedWitnessProof({wp, trustedWitnesses}) { + const vmDid = wp.verificationMethod?.split('#')[0]; + const entry = trustedWitnesses.find(tw => tw.id === vmDid); + if(!entry) { + return false; + } + if(!wp.created) { + return false; + } + const created = new Date(wp.created); + if(entry.validFrom && created < new Date(entry.validFrom)) { + return false; + } + if(entry.validUntil && created > new Date(entry.validUntil)) { + return false; + } + return true; +} + +/** + * Returns SHA-256(JCS(proof without `proofValue`)) as a Uint8Array. + * This is the first half of `verifyData` for both ecdsa-jcs-2019 schemes. + * + * @param {object} proof - The proof object. + * @returns {Uint8Array} SHA-256 digest of the canonicalized proof options. + */ +function _hashProofOptions(proof) { + const proofOptions = {...proof}; + delete proofOptions.proofValue; + return new Uint8Array( + crypto.createHash('sha256').update(canonicalize(proofOptions)).digest()); +} + +/** + * Builds an EcdsaMultikey verifier from a `did:key:` verification method URI. + * + * @param {string} vmId - Full verificationMethod URI (`did:key:z…#z…`). + * @returns {Promise} EcdsaMultikey verifier. + */ +async function _buildEcdsaVerifier(vmId) { + const didKeyId = vmId.split('#')[0]; + const publicKeyMultibase = didKeyId.replace('did:key:', ''); + const keyPair = await EcdsaMultikey.from({ + type: 'Multikey', + id: vmId, + controller: didKeyId, + publicKeyMultibase }); + return keyPair.verifier(); +} - return cel; +/** + * Verifies an operation proof (ecdsa-jcs-2019). + * VerifyData = SHA256(JCS(proofOptions)) || SHA256(JCS(event without proof)). + * The signing key must appear in `prevDidDocument.heartbeat[]`. + * + * @param {object} options - Configuration options. + * @param {object} options.event - The event object. + * @param {object} options.opProof - The operation proof. + * @param {object} options.prevDidDocument - Document used for key lookup. + * @returns {Promise} True if the proof is valid. + */ +async function _verifyOperationProof({event, opProof, prevDidDocument}) { + const vmRef = opProof.verificationMethod; + if(!vmRef?.startsWith('did:key:')) { + throw new Error( + `operation proof verificationMethod must be a did:key: URI: ${vmRef}`); + } + + // signing key hash must appear in heartbeat[]; for the create event the + // caller passes the create document itself, so hbKey0 is found there + const didKeyId = vmRef.split('#')[0]; + const hash = sha3256Multibase(didKeyId); + const heartbeat = prevDidDocument?.heartbeat ?? []; + if(!heartbeat.includes(hash)) { + throw new Error(`verification method not found in heartbeat: ${vmRef}`); + } + + // previousEventHash is set before signing, so it is covered by the proof + const doc = {...event}; + delete doc.proof; + const proofHash = _hashProofOptions(opProof); + const docHash = new Uint8Array( + crypto.createHash('sha256').update(canonicalize(doc)).digest()); + + const verifyData = new Uint8Array(proofHash.length + docHash.length); + verifyData.set(proofHash, 0); + verifyData.set(docHash, proofHash.length); + + const verifier = await _buildEcdsaVerifier(vmRef); + const sigBytes = base58Decode(opProof.proofValue.slice(1)); + return verifier.verify({data: verifyData, signature: sigBytes}); +} + +/** + * Verifies a witness proof (blind-witness ecdsa-jcs-2019). + * VerifyData = SHA256(JCS(proofOptions)) || SHA3-256(JCS(event)). + * The raw SHA3-256 digest (not a multihash) is used because the witness + * receives and signs the digest directly without multihash framing. + * + * @param {object} options - Configuration 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}) { + if(witnessProof.proofPurpose !== 'assertionMethod') { + throw new Error( + `witness proof proofPurpose must be "assertionMethod", ` + + `got "${witnessProof.proofPurpose}"`); + } + + // rawHash matches the digest sent to the witness service + const rawHash = sha3_256( + new TextEncoder().encode(canonicalize(logEntry.event))); + + const proofHash = _hashProofOptions(witnessProof); + const verifyData = new Uint8Array(proofHash.length + rawHash.length); + verifyData.set(proofHash, 0); + verifyData.set(rawHash, proofHash.length); + + const verifier = await _buildEcdsaVerifier(witnessProof.verificationMethod); + const sigBytes = base58Decode(witnessProof.proofValue.slice(1)); + return verifier.verify({data: verifyData, signature: sigBytes}); +} + +/** + * Loads and validates a gzip-compressed CEL from disk. See `read()`. + * + * @param {object} options - Configuration options. + * @param {string} options.filename - Path to the `.cel` file. + * @param {Array} [options.trustedWitnesses=[]] - See `read()`. + * @param {string|null} [options.versionTime=null] - See `read()`. + * @returns {Promise} See `read()`. + */ +export async function loadFromFile( + {filename, trustedWitnesses = [], versionTime = null}) { + const compressed = readFileSync(filename); + const cel = JSON.parse(gunzipSync(compressed).toString('utf8')); + return read({cel, trustedWitnesses, versionTime}); +} + +/** + * Serializes a CEL to gzip-compressed JSON and writes it to disk. + * + * @param {object} options - Configuration options. + * @param {string} options.filename - Destination path. + * @param {object} options.cel - The CEL to save. + */ +export function saveToFile({filename, cel}) { + const compressed = gzipSync(Buffer.from(JSON.stringify(cel), 'utf8')); + writeFileSync(filename, compressed); } -export default {create, update, witness}; +export default {addEvent, create, loadFromFile, read, saveToFile, witness}; diff --git a/lib/didcel.js b/lib/didcel.js index 033a56b..946a79e 100644 --- a/lib/didcel.js +++ b/lib/didcel.js @@ -1,122 +1,212 @@ +/** + * @file DID document creation and management for the did:cel method. + */ + 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 {assertValidDidDocument} from './validate.js'; import canonicalize from 'canonicalize'; +import {create as celCreate} from './cel.js'; import {createSignCryptosuite} from '@digitalbazaar/ecdsa-jcs-2019-cryptosuite'; +import crypto from 'node:crypto'; +import {DataIntegrityProof} from '@digitalbazaar/data-integrity'; +import {hkdf} from '@noble/hashes/hkdf.js'; import jsigs from 'jsonld-signatures'; -import * as mfHasher from 'multiformats/hashes/hasher'; -import {sha3_256} from '@noble/hashes/sha3.js'; +import {JsonLdDocumentLoader} from 'jsonld-document-loader'; +import {sha256} from '@noble/hashes/sha2.js'; +import {sha3256Multibase} from './utils.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', - assertionMethod: [publicKey] - } - - // generate the did:cel identifier - const utf8Encoder = new TextEncoder(); - const canonicalizedDidDocument = canonicalize(didDocument); - 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); +/** + * Creates a new did:cel DID document. The DID identifier is derived from + * SHA3-256(JCS(initial document)) so the identifier is self-certifying. + * The document contains no assertionMethod keys at creation time; add + * verification methods afterwards via `addVm()`. + * + * @param {object} [options] - Configuration options. + * @param {string} [options.heartbeatFrequency='P1M'] - ISO 8601 duration. + * @param {object[]} [options.service] - Service entries to include in the DID + * document. Each must be a valid DID Core service object. If omitted, no + * `service` property is set on the document. + * @returns {Promise} `{heartbeatSecret, didDocument, + * cryptographicEventLog}`. + */ +export async function create({heartbeatFrequency = 'P1M', service} = {}) { + // derive the initial heartbeat key from a fresh 128-bit master secret + const heartbeatSecret = crypto.randomBytes(16); + const heartbeatKeyPair = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const heartbeatPublicKey = + await heartbeatKeyPair.export({publicKey: true, includeContext: false}); + + // store only the hash of the did:key URI, not the key itself + const heartbeatDidKey = `did:key:${heartbeatPublicKey.publicKeyMultibase}`; + const heartbeatHash = sha3256Multibase(heartbeatDidKey); + + const didDocument = { + '@context': [ + 'https://www.w3.org/ns/did/v1.1', + 'https://w3id.org/didcel/v1' + ], + heartbeatFrequency, + heartbeat: [heartbeatHash], + ...(service !== undefined && {service}) + }; + + // hash the draft document (before `id` is set) to produce the DID + const encodedHash = sha3256Multibase(canonicalize(didDocument)); 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 - }); + assertValidDidDocument({didDocument}); - // create signed credential - 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 - signedDidDocument.proof.verificationMethod = controller + publicKey.id; - - // 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 - } + const event = {operation: {type: 'create', data: didDocument}}; + const signedEvent = + await _signEvent({event, signer: heartbeatKeyPair.signer()}); + + const cryptographicEventLog = celCreate({event: signedEvent}); - return {keyPair, didDocument}; + return {heartbeatSecret, didDocument, cryptographicEventLog}; } -export async function addVm({didDocument, verificationRelationship, curve}) { - // TODO: replace with modern clone - const newDidDocument = JSON.parse(JSON.stringify(didDocument)); +/** + * Derives an ECDSA P-256 key pair from a heartbeat master secret and an + * index using HKDF-SHA256. Index 0 is used at create time; index i signs + * the i-th subsequent heartbeat event. + * + * @param {Buffer|Uint8Array} masterSecret - 16-byte master secret. + * @param {number} index - Non-negative derivation index. + * @returns {Promise} EcdsaMultikey key pair. + */ +export async function deriveHeartbeatKeyPair(masterSecret, index) { + // encode index as 4-byte big-endian info for HKDF domain separation + const info = new Uint8Array(4); + new DataView(info.buffer).setUint32(0, index, false); + const salt = new TextEncoder().encode('did:cel:heartbeat-v1'); + const secretKey = hkdf(sha256, masterSecret, salt, info, 32); + // derive the compressed P-256 public key via Node.js ECDH (no extra dep) + const ecdhObj = crypto.createECDH('prime256v1'); + ecdhObj.setPrivateKey(secretKey); + const publicKey = new Uint8Array(ecdhObj.getPublicKey(null, 'compressed')); const keyPair = + await EcdsaMultikey.fromRaw({curve: 'P-256', secretKey, publicKey}); + // set id/controller so the key pair is self-describing as a did:key document + const exported = + await keyPair.export({publicKey: true, includeContext: false}); + const didKeyId = `did:key:${exported.publicKeyMultibase}`; + keyPair.id = didKeyId; + keyPair.controller = didKeyId; + return keyPair; +} + +/** + * Adds a verification method to the specified relationship on a DID document. + * The proof is stripped and must be regenerated via `createEvent` before the + * document is used. + * + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to modify. + * @param {string} options.verificationRelationship - Target relationship + * (e.g. `'authentication'`, `'keyAgreement'`). + * @param {object} [options.keyPair] - An existing EcdsaMultikey key pair to + * add. If omitted, a new one is generated using `curve`. + * @param {string} [options.curve='P-256'] - Elliptic curve for key generation. + * Ignored if `keyPair` is supplied. + * @returns {Promise} `{keyPair, didDocument}` (no proof attached). + */ +export async function addVm( + {didDocument, verificationRelationship, keyPair: suppliedKeyPair, curve}) { + const newDidDocument = structuredClone(didDocument); + const keyPair = suppliedKeyPair ?? 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); + _registerKeyWithDocumentLoader(publicKey, publicKey.controller); 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; +/** + * Creates and signs an event. `previousEventHash` must be obtained via + * `getPreviousEventHash()` and set before signing so the hash chain is + * covered by the proof. + * + * @param {object} options - Configuration options. + * @param {string} options.type - Event type: `'update'`, `'heartbeat'`, or + * `'deactivate'`. + * @param {object} [options.data] - Operation data: full DID document for + * `update`, `{heartbeat:[…]}` for `heartbeat`, omit for `deactivate`. + * @param {object} options.signingKeyPair - Heartbeat key pair to sign with. + * @param {string} [options.previousEventHash] - Hash of the prior event. + * @returns {Promise} The signed event with proof attached. + */ +export async function createEvent( + {type, data, signingKeyPair, previousEventHash}) { + const operation = {type}; + if(data !== undefined) { + operation.data = data; + } + const event = {operation}; + if(previousEventHash !== undefined) { + event.previousEventHash = previousEventHash; } + return _signEvent({event, signer: signingKeyPair.signer()}); +} + +/** + * Sets `heartbeatFrequency` on a DID document. The proof is stripped and + * must be regenerated via `createEvent` before the document is used. + * + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to modify. + * @param {string} options.heartbeatFrequency - ISO 8601 duration + * (e.g. `'P1D'`). + * @returns {object} `{didDocument}` (no proof). + */ +export function setHeartbeatFrequency({didDocument, heartbeatFrequency}) { + const newDidDocument = structuredClone(didDocument); + newDidDocument.heartbeatFrequency = heartbeatFrequency; + delete newDidDocument.proof; + return {didDocument: newDidDocument}; +} + +/** + * Registers a public key with the JSON-LD document loader under both its + * fragment id (`#zAbc…`) and its controller-qualified id (`did:cel:z…#zAbc…`), + * so jsigs can resolve the verification method during signing and verification. + * + * @param {object} publicKey - Exported key with `id` set to the fragment form. + * @param {string} controller - The DID controller URI. + */ +function _registerKeyWithDocumentLoader(publicKey, controller) { + jdl.addStatic(publicKey.id, publicKey); + const fullId = controller + publicKey.id; + jdl.addStatic(fullId, { + ...publicKey, id: fullId, + '@context': 'https://w3id.org/security/multikey/v1' + }); +} - // create signed DID document - let documentLoader = jdl.build(); - const ecdsaJcs2019Cryptosuite = createSignCryptosuite(); +async function _signEvent({event, signer}) { const suite = new DataIntegrityProof({ - signer: assertionMethod.signer(), cryptosuite: ecdsaJcs2019Cryptosuite + signer, cryptosuite: createSignCryptosuite() }); - const signedDidDocument = await jsigs.sign(newDidDocument, { + return jsigs.sign(event, { suite, purpose: new AssertionProofPurpose(), - documentLoader + documentLoader: jdl.build() }); - - // TODO: determine if there is a better way to set verificationMethod - newDidDocument.proof.verificationMethod = newDidDocument.id + '#' + - assertionMethod.publicKeyMultibase; - - return {didDocument: newDidDocument}; } -export default {create, addVm, updateProof}; +export default { + addVm, create, createEvent, deriveHeartbeatKeyPair, setHeartbeatFrequency +}; diff --git a/lib/index.js b/lib/index.js index e69de29..fc53703 100644 --- a/lib/index.js +++ b/lib/index.js @@ -0,0 +1,25 @@ +// cel.js: CEL read, write, and validation +export { + addEvent, create as createCel, getPreviousEventHash, loadFromFile, read, + saveToFile, witness +} from './cel.js'; + +// didcel.js: DID document creation and management +export { + addVm, create, createEvent, deriveHeartbeatKeyPair, setHeartbeatFrequency +} from './didcel.js'; + +// secrets.js: encrypted private key storage +export {loadSecrets, saveSecrets} from './secrets.js'; + +// utils.js: hashing, pretty-printing, and DID document utilities +export { + deleteObjectByIdSuffix, + getObjectByIdSuffix, + prettyPrintCel, + sha3256Multibase, + VERIFICATION_RELATIONSHIPS +} from './utils.js'; + +// witness.js: witness service HTTP client +export {witness as witnessService} from './witness.js'; diff --git a/lib/secrets.js b/lib/secrets.js new file mode 100644 index 0000000..4855393 --- /dev/null +++ b/lib/secrets.js @@ -0,0 +1,145 @@ +/** + * @file Encrypted private key storage. + * Saves and loads private keys to `{secretsDir}/{didIdentifier}.yaml`. + * Each `secretKeyMultibase` is encrypted with AES-256-GCM; the encryption + * key is derived from a user-supplied password via scrypt. + */ + +import * as EcdsaMultikey from '@digitalbazaar/ecdsa-multikey'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import crypto from 'node:crypto'; +import {join} from 'node:path'; +import {VERIFICATION_RELATIONSHIPS} from './utils.js'; +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 private key pairs for a DID to a YAML secrets file. + * The heartbeat master secret is encoded as base64url multibase before + * encryption so it can be round-tripped alongside the key pairs. + * + * @param {object} options - Configuration options. + * @param {string} options.didIdentifier - Method-specific ID (after + * `did:cel:`). + * @param {object} options.secretKeys - Keys keyed by verification relationship, + * each an array of key pair objects, plus `heartbeat` as a Buffer. + * @param {string} options.password - Encryption password. + * @param {string} options.secretsDir - Directory to write the secrets file to. + */ +export async function saveSecrets({ + didIdentifier, secretKeys, password, secretsDir +}) { + const keys = []; + for(const [relationship, keyPairs] of Object.entries(secretKeys)) { + if(relationship === 'heartbeat') { + continue; + } + 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 = + _encrypt(secretKeyMultibase, password); + keys.push({...publicFields, relationship, encryptedSecretKeyMultibase}); + } + } + + // encode heartbeat secret as base64url multibase before encrypting + let encryptedHeartbeatSecret; + const {heartbeat} = secretKeys; + if(heartbeat instanceof Uint8Array || Buffer.isBuffer(heartbeat)) { + const multibase = 'u' + Buffer.from(heartbeat).toString('base64url'); + encryptedHeartbeatSecret = _encrypt(multibase, password); + } + + mkdirSync(secretsDir, {recursive: true}); + writeFileSync( + _secretsPath({didIdentifier, secretsDir}), + yaml.dump({keys, encryptedHeartbeatSecret}) + ); +} + +function _secretsPath({didIdentifier, secretsDir}) { + return join(secretsDir, `${didIdentifier}.yaml`); +} + +// Derives a 32-byte AES key from a password and salt using scrypt. +function _deriveKey(password, salt) { + return crypto.scryptSync(password, salt, KEY_LEN, + {N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P}); +} + +/** + * Loads and decrypts private keys from the secrets file for a DID. + * + * @param {object} options - Configuration options. + * @param {string} options.didIdentifier - Method-specific ID (after + * `did:cel:`). + * @param {string} options.password - Decryption password. + * @param {string} options.secretsDir - Directory containing the secrets file. + * @returns {Promise} Secret keys keyed by verification relationship, + * each an array of EcdsaMultikey key pairs, plus `heartbeat` as a Buffer. + */ +export async function loadSecrets({didIdentifier, password, secretsDir}) { + const secretsPath = _secretsPath({didIdentifier, secretsDir}); + if(!existsSync(secretsPath)) { + throw new Error(`Secrets file not found: ${secretsPath}`); + } + const {keys, encryptedHeartbeatSecret} = + yaml.load(readFileSync(secretsPath, 'utf8')) ?? {keys: []}; + + const secretKeys = Object.fromEntries( + VERIFICATION_RELATIONSHIPS.map(r => [r, []])); + + for(const entry of keys) { + const { + relationship, encryptedSecretKeyMultibase, ...publicFields + } = entry; + const secretKeyMultibase = + _decrypt(encryptedSecretKeyMultibase, password); + const keyPair = await EcdsaMultikey.from( + {...publicFields, secretKeyMultibase}); + if(secretKeys[relationship]) { + secretKeys[relationship].push(keyPair); + } + } + + if(encryptedHeartbeatSecret) { + const multibase = _decrypt(encryptedHeartbeatSecret, password); + secretKeys.heartbeat = Buffer.from(multibase.slice(1), 'base64url'); + } + + return secretKeys; +} + +// Wire format: salt(32) || iv(12) || tag(16) || ciphertext, base64-encoded. +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 = _deriveKey(password, salt); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return decipher.update(enc, undefined, 'utf8') + decipher.final('utf8'); +} + +function _encrypt(plaintext, password) { + const salt = crypto.randomBytes(32); + const iv = crypto.randomBytes(12); + const key = _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(); + return Buffer.concat([salt, iv, tag, enc]).toString('base64'); +} diff --git a/lib/utils.js b/lib/utils.js index f6d0609..dc5035d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,79 +1,204 @@ -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 = []; +import * as mfHasher from 'multiformats/hashes/hasher'; +import {base58btc} from 'multiformats/bases/base58'; +import {sha3_256} from '@noble/hashes/sha3.js'; - for(let pkey of preferOrder) { - if(value[pkey] !== undefined) { - prettyKeys.push(pkey); - } - } - for(let skey of sortedKeys) { - if(!preferOrder.includes(skey)) { - prettyKeys.push(skey); - } - } +export const VERIFICATION_RELATIONSHIPS = + ['assertionMethod', 'authentication', 'capabilityDelegation', + 'capabilityInvocation', 'keyAgreement']; - result = prettyKeys.reduce((sorted, key) => { - sorted[key] = value[key]; - return sorted; - }, {}); - } +// module-level hasher — stateless, reusable across all calls +const _sha3256Hasher = mfHasher.from({ + name: 'sha3-256', + code: 0x16, + encode: data => sha3_256(data) +}); - return result; - } +/** + * Returns the base58btc multibase-encoded SHA3-256 multihash of a string. + * This is the canonical hashing primitive used throughout the did:cel method. + * + * @param {string} input - UTF-8 string to hash. + * @returns {string} `z`-prefixed base58btc multibase string. + */ +export function sha3256Multibase(input) { + const {bytes} = + _sha3256Hasher.digest(new TextEncoder().encode(input)); + return base58btc.encode(bytes); } -export function getObjectByIdSuffix({didDocument, suffix}) { - let rval = undefined; - for(let property of Object.keys(didDocument)) { +/** + * Finds the first object in any array property of a DID document whose `id` + * ends with `suffix`. Returns location metadata, or null if not found. + * + * @param {object} didDocument - The DID document to search. + * @param {string} suffix - The id suffix to match. + * @returns {{property: string, index: number, entry: object}|null} Location + * metadata, or null if not found. + */ +function _findByIdSuffix(didDocument, suffix) { + for(const property of Object.keys(didDocument)) { if(!Array.isArray(didDocument[property])) { continue; } - for(let entry of didDocument[property]) { + const arr = didDocument[property]; + for(let i = 0; i < arr.length; i++) { + const entry = arr[i]; if(typeof entry !== 'object') { continue; } - const idSuffix = - entry.id.slice(entry.id.length - suffix.length, entry.id.length); - if(suffix === idSuffix) { - rval = entry; + if(entry.id.endsWith(suffix)) { + return {property, index: i, entry}; } } } + return null; +} - return rval; +/** + * Returns the first object in any array property of a DID document whose + * `id` ends with `suffix`, or undefined if not found. + * + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to search. + * @param {string} options.suffix - The id suffix to match (e.g. `'#zAbc…'`). + * @returns {object|undefined} The matched entry, or undefined if not found. + */ +export function getObjectByIdSuffix({didDocument, suffix}) { + return _findByIdSuffix(didDocument, suffix)?.entry; } +/** + * Removes and returns the first object in any array property of a DID + * document whose `id` ends with `suffix`. Mutates `didDocument` in place. + * + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to modify. + * @param {string} options.suffix - The id suffix to match (e.g. `'#zAbc…'`). + * @returns {object|undefined} The removed object, or undefined if not found. + */ export function deleteObjectByIdSuffix({didDocument, suffix}) { - let rval = undefined; - for(let property of Object.keys(didDocument)) { - if(!Array.isArray(didDocument[property])) { - continue; + const found = _findByIdSuffix(didDocument, suffix); + if(!found) { + return undefined; + } + const {property, index, entry} = found; + didDocument[property].splice(index, 1); + return entry; +} + +/** + * Recursively reorders object keys for stable JSON output: + * `@context`, `id`, `type` first; `proof` last; all others alphabetical. + * + * @param {*} val - Any JSON-serializable value. + * @returns {*} Value with all nested object keys reordered. + */ +function _reorder(val) { + if(Array.isArray(val)) { + return val.map(_reorder); + } + if(val !== null && typeof val === 'object') { + const FIRST = ['@context', 'id', 'type']; + const keys = Object.keys(val); + const ordered = [ + ...FIRST.filter(k => keys.includes(k)), + ...keys.filter(k => !FIRST.includes(k) && k !== 'proof').sort(), + ...keys.filter(k => k === 'proof') + ]; + const out = {}; + for(const k of ordered) { + out[k] = _reorder(val[k]); } + return out; + } + return val; +} - 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; +/** + * Collapses adjacent opener pairs in an indented JSON string so that + * `[\n {` becomes `[{`, reducing visual nesting depth. + * + * @param {string} str - Indented JSON string. + * @returns {string} JSON string with adjacent opener pairs collapsed. + */ +function _collapseBrackets(str) { + let prev; + do { + prev = str; + const lines = str.split('\n'); + const out = []; + let i = 0; + while(i < lines.length) { + const line = lines[i]; + + if(i + 1 < lines.length && /[{\[]\s*$/.test(line)) { + const m = lines[i + 1].match(/^(\s*)([{\[])\s*$/); + if(m) { + const nextOpener = m[2]; + let depth = 1; + let k = i + 2; + while(k < lines.length && depth > 0) { + for(const ch of lines[k]) { + if(ch === '{' || ch === '[') { + depth++; + } else if(ch === '}' || ch === ']') { + depth--; + if(depth === 0) { + break; + } + } + } + if(depth > 0) { + k++; + } + } + out.push(line.replace(/\s*$/, '') + nextOpener); + for(let j = i + 2; j < k; j++) { + out.push(lines[j].replace(/^ /, '')); + } + if(k < lines.length) { + const innerCloser = lines[k].replace(/^ /, ''); + const outerCloserMatch = k + 1 < lines.length && + lines[k + 1].match(/^(\s*)([}\]])(,?)\s*$/); + if(outerCloserMatch) { + out.push( + outerCloserMatch[1] + innerCloser.trim() + + outerCloserMatch[2] + outerCloserMatch[3]); + i = k + 2; + } else { + out.push(innerCloser); + i = k + 1; + } + } else { + i = k + 1; + } + continue; + } } - }) - } - return rval; + out.push(line); + i++; + } + str = out.join('\n'); + } while(str !== prev); + return str; +} + +/** + * Serializes a CEL or DID document to a human-readable JSON string with + * stable key ordering (`@context`/`id`/`type` first, `proof` last, + * others alphabetical) and collapsed adjacent bracket pairs. + * + * @param {object} obj - The object to serialize. + * @returns {string} Formatted JSON string. + */ +export function prettyPrintCel(obj) { + return _collapseBrackets(JSON.stringify(_reorder(obj), null, 2)); } export default { - createJsonldPrettyPrinter, deleteObjectByIdSuffix, - getObjectByIdSuffix + getObjectByIdSuffix, + prettyPrintCel, + sha3256Multibase }; diff --git a/lib/validate.js b/lib/validate.js new file mode 100644 index 0000000..9fe238e --- /dev/null +++ b/lib/validate.js @@ -0,0 +1,243 @@ +/** + * @file JSON Schema validation for did:cel DID documents and CELs. + */ + +import addFormats from 'ajv-formats'; +import Ajv from 'ajv/dist/2020.js'; + +const ajv = new Ajv({allErrors: true, strict: false}); +addFormats(ajv); + +// Reusable sub-schemas +const MULTIBASE_STRING = {type: 'string', pattern: '^z[1-9A-HJ-NP-Za-km-z]+'}; +const DID_CEL = {type: 'string', pattern: '^did:cel:z[1-9A-HJ-NP-Za-km-z]+'}; +const ISO_DATETIME = {type: 'string'}; +const ISO_DURATION = {type: 'string', pattern: '^P'}; + +const VERIFICATION_METHOD = { + type: 'object', + required: ['id', 'type', 'controller'], + properties: { + id: {type: 'string'}, + type: {type: 'string', enum: ['Multikey', 'JsonWebKey']}, + controller: {type: 'string'}, + publicKeyMultibase: MULTIBASE_STRING, + publicKeyJwk: {type: 'object'} + }, + additionalProperties: true +}; + +const SERVICE_ENTRY = { + type: 'object', + required: ['type', 'serviceEndpoint'], + properties: { + type: {type: 'string', const: 'CelStorageService'}, + serviceEndpoint: { + type: 'array', + items: {type: 'string', format: 'uri'}, + minItems: 1 + } + }, + additionalProperties: true +}; + +const DID_DOCUMENT_SCHEMA = { + type: 'object', + required: ['@context', 'id', 'heartbeatFrequency', 'heartbeat'], + properties: { + '@context': { + type: 'array', + prefixItems: [ + {type: 'string', const: 'https://www.w3.org/ns/did/v1.1'}, + {type: 'string', const: 'https://w3id.org/didcel/v1'} + ], + minItems: 2, + items: {type: 'string'} + }, + id: DID_CEL, + heartbeatFrequency: ISO_DURATION, + assertionMethod: { + type: 'array', + items: VERIFICATION_METHOD, + minItems: 1 + }, + authentication: {type: 'array', items: VERIFICATION_METHOD, minItems: 1}, + keyAgreement: {type: 'array', items: VERIFICATION_METHOD, minItems: 1}, + capabilityDelegation: { + type: 'array', items: VERIFICATION_METHOD, minItems: 1 + }, + capabilityInvocation: { + type: 'array', items: VERIFICATION_METHOD, minItems: 1 + }, + heartbeat: { + type: 'array', + items: MULTIBASE_STRING, + minItems: 1 + }, + service: { + type: 'array', + items: SERVICE_ENTRY, + minItems: 1 + } + }, + additionalProperties: true +}; + +const DATA_INTEGRITY_PROOF = { + type: 'object', + required: ['type', 'cryptosuite', 'proofPurpose', 'proofValue', + 'verificationMethod'], + properties: { + type: {type: 'string', const: 'DataIntegrityProof'}, + cryptosuite: {type: 'string'}, + created: ISO_DATETIME, + proofPurpose: {type: 'string'}, + proofValue: MULTIBASE_STRING, + verificationMethod: {type: 'string'} + }, + additionalProperties: true +}; + +// Witness proofs additionally require `created` +const WITNESS_PROOF = { + ...DATA_INTEGRITY_PROOF, + required: [...DATA_INTEGRITY_PROOF.required, 'created'] +}; + +const CREATE_OPERATION = { + type: 'object', + required: ['type', 'data'], + properties: { + type: {type: 'string', const: 'create'}, + data: DID_DOCUMENT_SCHEMA + }, + additionalProperties: false +}; + +const UPDATE_OPERATION = { + type: 'object', + required: ['type', 'data'], + properties: { + type: {type: 'string', const: 'update'}, + data: DID_DOCUMENT_SCHEMA + }, + additionalProperties: false +}; + +const HEARTBEAT_OPERATION = { + type: 'object', + required: ['type', 'data'], + properties: { + type: {type: 'string', const: 'heartbeat'}, + data: { + type: 'object', + required: ['heartbeat'], + properties: { + heartbeat: {type: 'array', items: MULTIBASE_STRING, minItems: 1} + }, + additionalProperties: false + } + }, + additionalProperties: false +}; + +const DEACTIVATE_OPERATION = { + type: 'object', + required: ['type'], + properties: { + type: {type: 'string', const: 'deactivate'} + }, + additionalProperties: false +}; + +const CREATE_EVENT = { + type: 'object', + required: ['operation', 'proof'], + properties: { + '@context': {}, + operation: CREATE_OPERATION, + proof: DATA_INTEGRITY_PROOF + }, + additionalProperties: false +}; + +const NON_CREATE_EVENT = { + type: 'object', + required: ['previousEventHash', 'operation', 'proof'], + properties: { + '@context': {}, + previousEventHash: MULTIBASE_STRING, + operation: { + oneOf: [UPDATE_OPERATION, HEARTBEAT_OPERATION, DEACTIVATE_OPERATION]}, + proof: DATA_INTEGRITY_PROOF + }, + additionalProperties: false +}; + +const CREATE_LOG_ENTRY = { + type: 'object', + required: ['event'], + properties: { + event: CREATE_EVENT, + proof: {type: 'array', items: WITNESS_PROOF} + }, + additionalProperties: false +}; + +const NON_CREATE_LOG_ENTRY = { + type: 'object', + required: ['event'], + properties: { + event: NON_CREATE_EVENT, + proof: {type: 'array', items: WITNESS_PROOF} + }, + additionalProperties: false +}; + +const CEL_SCHEMA = { + type: 'object', + required: ['log'], + properties: { + log: { + type: 'array', + minItems: 1, + prefixItems: [CREATE_LOG_ENTRY], + items: NON_CREATE_LOG_ENTRY + } + }, + additionalProperties: false +}; + +const validateDidDocument = ajv.compile(DID_DOCUMENT_SCHEMA); +const validateCel = ajv.compile(CEL_SCHEMA); + +/** + * Throws if `didDocument` does not conform to the did:cel JSON Schema. + * + * @param {object} options - Configuration options. + * @param {object} options.didDocument - The DID document to validate. + * @throws {Error} Describing all schema violations. + */ +export function assertValidDidDocument({didDocument}) { + if(!validateDidDocument(didDocument)) { + const details = ajv.errorsText( + validateDidDocument.errors, {separator: '; '}); + throw new Error(`Invalid DID document: ${details}`); + } +} + +/** + * Throws if `cel` does not conform to the did:cel JSON Schema. + * + * @param {object} options - Configuration options. + * @param {object} options.cel - The CEL to validate. + * @throws {Error} Describing all schema violations. + */ +export function assertValidCel({cel}) { + if(!validateCel(cel)) { + const details = ajv.errorsText(validateCel.errors, {separator: '; '}); + throw new Error(`Invalid CEL: ${details}`); + } +} + +export default {assertValidCel, assertValidDidDocument}; diff --git a/lib/witness.js b/lib/witness.js index 3e95a03..e2c2542 100644 --- a/lib/witness.js +++ b/lib/witness.js @@ -1,70 +1,40 @@ -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}); - - // 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 +/** + * @file HTTP client for the did:cel blind witness service. + */ + +import fetch from 'node-fetch'; +import https from 'node:https'; + +/** + * Sends a `digestMultibase` to a witness service and returns its proof. + * + * @param {object} options - Configuration options. + * @param {string} options.digestMultibase - Base58btc SHA3-256 multihash of + * the event to attest (`z`-prefixed). + * @param {string} options.witnessUrl - Witness endpoint URL. + * @param {boolean} [options.allowSelfSigned=false] - When true, accept + * self-signed TLS certificates. Only enable this for local development; + * never use in production. + * @returns {Promise} DataIntegrityProof returned by the witness. + */ +export async function witness({digestMultibase, witnessUrl, allowSelfSigned}) { + const {protocol} = new URL(witnessUrl); + let agent; + if(protocol === 'https:' && allowSelfSigned) { + agent = new https.Agent({rejectUnauthorized: false}); + } + const response = await fetch(witnessUrl, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({digestMultibase}), + agent }); - - 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 {witness}; diff --git a/package.json b/package.json index a2974aa..e068ea8 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,26 @@ { "name": "didcel", - "version": "0.0.1", + "version": "0.9.0", + "description": "JavaScript library for creating and managing DIDs using the did:cel method, secured by a Cryptographic Event Log (CEL).", "type": "module", "main": "./lib/index.js", + "exports": { + ".": "./lib/index.js" + }, + "engines": { + "node": ">=24" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "lint": "eslint .", + "test": "mocha" }, - "keywords": [], + "keywords": [ + "did", + "did:cel", + "decentralized-identifiers", + "cryptographic-event-log", + "verifiable-credentials" + ], "author": { "name": "Digital Bazaar, Inc.", "url": "https://digitalbazaar.com/" @@ -14,17 +28,24 @@ "contributors": [ "Manu Sporny (https://manu.sporny.org/)" ], - "license": "BSD", - "description": "", + "license": "BSD-3-Clause", "dependencies": { "@digitalbazaar/data-integrity": "^2.5.0", "@digitalbazaar/ecdsa-jcs-2019-cryptosuite": "^1.0.0", "@noble/hashes": "^2.0.1", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "canonicalize": "^2.1.0", - "commander": "^12.1.0", - "dotenv": "^16.4.5", "jsonld-document-loader": "^2.3.0", + "moment": "^2.30.1", "multiformats": "^13.4.1", - "prompt-sync": "^4.2.0" + "node-fetch": "^3.3.2" + }, + "devDependencies": { + "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/mocha/00-setup.js b/tests/mocha/00-setup.js new file mode 100644 index 0000000..ec08790 --- /dev/null +++ b/tests/mocha/00-setup.js @@ -0,0 +1,7 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import {start, stop} from './mock-witness.js'; + +before(() => start()); +after(() => stop()); diff --git a/tests/mocha/10-create.js b/tests/mocha/10-create.js new file mode 100644 index 0000000..c8eaffc --- /dev/null +++ b/tests/mocha/10-create.js @@ -0,0 +1,62 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import chai from 'chai'; +import {create} from '../../lib/index.js'; + +const {expect} = chai; + +describe('create', function() { + this.timeout(30000); + + it('should create a well-formed DID document without service', async () => { + const {didDocument, cryptographicEventLog, heartbeatSecret} = + await create(); + + // identifier + expect(didDocument.id).to.match(/^did:cel:z/); + + // JSON-LD contexts + expect(didDocument['@context']).to.be.an('array'); + expect(didDocument['@context']).to.include('https://www.w3.org/ns/did/v1.1'); + expect(didDocument['@context']).to.include('https://w3id.org/didcel/v1'); + + // heartbeat frequency + expect(didDocument.heartbeatFrequency).to.be.a('string').that.is.not.empty; + + // assertionMethod: absent at create time — keys are added via addVm() + expect(didDocument.assertionMethod).to.be.undefined; + + // heartbeat: one base58btc-encoded SHA3-256 multihash of a did:key URI + expect(didDocument.heartbeat).to.be.an('array').with.length(1); + const heartbeatHash = didDocument.heartbeat[0]; + expect(heartbeatHash).to.be.a('string').that.matches(/^z/); + + // heartbeatSecret: 16-byte KDF master secret returned to caller for storage + expect(Buffer.isBuffer(heartbeatSecret)).to.be.true; + expect(heartbeatSecret).to.have.length(16); + + // no service property when none supplied + expect(didDocument.service).to.be.undefined; + + // CEL create event + const createEntry = cryptographicEventLog.log[0]; + expect(createEntry.event.operation.type).to.equal('create'); + expect(createEntry.event.operation.data.id).to.equal(didDocument.id); + expect(createEntry.event.proof).to.have.property( + 'type', 'DataIntegrityProof'); + }); + + it('should include service endpoints when supplied', async () => { + const service = [{ + type: 'CelStorageService', + serviceEndpoint: ['https://storage.example/v1'] + }]; + const {didDocument} = await create({service}); + + expect(didDocument.service).to.be.an('array').with.length(1); + expect(didDocument.service[0]).to.have.property('type', 'CelStorageService'); + expect(didDocument.service[0].serviceEndpoint).to.deep.equal( + ['https://storage.example/v1']); + }); +}); diff --git a/tests/mocha/20-witness.js b/tests/mocha/20-witness.js new file mode 100644 index 0000000..2c7ede7 --- /dev/null +++ b/tests/mocha/20-witness.js @@ -0,0 +1,88 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + create, getPreviousEventHash, read, witness +} from '../../lib/index.js'; +import chai from 'chai'; +import {TEST_WITNESS_DIDS, TEST_WITNESSES} from './helpers.js'; + +const {expect} = chai; + +async function runCreateAndWitness() { + const {didDocument, cryptographicEventLog} = await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + return {didDocument, cryptographicEventLog}; +} + +function getTrustedWitnesses() { + return TEST_WITNESS_DIDS.map(id => ({ + id, + validFrom: '2000-01-01T00:00:00Z', + validUntil: '2099-01-01T00:00:00Z' + })); +} + +describe('witness', function() { + this.timeout(60000); + + it('should attach a DataIntegrityProof to the create event', async () => { + const {didDocument, cryptographicEventLog} = await runCreateAndWitness(); + + expect(didDocument.id).to.match(/^did:cel:/); + expect(cryptographicEventLog.log).to.have.length(1); + + const createEntry = cryptographicEventLog.log[0]; + expect(createEntry.event.operation.type).to.equal('create'); + expect(createEntry).to.have.property('proof'); + expect(createEntry.proof).to.be.an('array').with.length.at.least(1); + expect(createEntry.proof[0]).to.have.property('type', 'DataIntegrityProof'); + expect(createEntry.proof[0]).to.have.property('verificationMethod'); + }); + + it('should throw when no witnesses are provided', async () => { + const {cryptographicEventLog} = await create(); + + let error; + try { + await witness({cel: cryptographicEventLog, witnesses: []}); + } catch(e) { + error = e; + } + + expect(error).to.exist; + expect(error.message).to.include('witnesses'); + }); + + it('should produce a stable previousEventHash before and after witnessing', + async () => { + const {cryptographicEventLog} = await create(); + const hashBefore = getPreviousEventHash({cel: cryptographicEventLog}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + // witness proofs attach to the log entry wrapper, not the event itself — + // the hash must be identical after witnessing + const hashAfter = getPreviousEventHash({cel: cryptographicEventLog}); + + expect(hashBefore).to.be.a('string').that.matches(/^z/); + expect(hashAfter).to.equal(hashBefore); + }); + + it('should reject an operation proof signed with a key not in heartbeat[]', + async () => { + const {cryptographicEventLog} = await runCreateAndWitness(); + + // replace the verificationMethod with a random did:key that was never + // registered in heartbeat[], so the key lookup fails + const tampered = structuredClone(cryptographicEventLog); + tampered.log[0].event.proof.verificationMethod = + 'did:key:zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv' + + '#zDnaerx9CtbPJ1q36T5Ln5wYt3MQYeGRG5ehnPAmxcf5mDZpv'; + + const {valid, errors} = + await read({cel: tampered, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid).to.be.false; + expect(errors.some(e => + e.includes('operation proof') || e.includes('heartbeat'))).to.be.true; + }); +}); diff --git a/tests/mocha/30-update.js b/tests/mocha/30-update.js new file mode 100644 index 0000000..bf50320 --- /dev/null +++ b/tests/mocha/30-update.js @@ -0,0 +1,104 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, + getPreviousEventHash, witness +} from '../../lib/index.js'; +import {computeHeartbeatHash, TEST_WITNESSES} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +async function runUpdate() { + const {heartbeatSecret, didDocument, cryptographicEventLog} = await create(); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + + const {didDocument: updatedDoc} = await addVm({ + didDocument, + verificationRelationship: 'authentication' + }); + // rotation is required for every non-deactivate event + updatedDoc.heartbeat = [await computeHeartbeatHash(heartbeatSecret, 1)]; + + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const updateEvent = await createEvent({ + type: 'update', + data: updatedDoc, + signingKeyPair: hbKey0, + previousEventHash + }); + await addEvent({cel: cryptographicEventLog, event: updateEvent}); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + return {cryptographicEventLog}; +} + +describe('update', function() { + this.timeout(120000); + + it('should produce a 2-event hash-linked CEL with witness proofs', + async () => { + const {cryptographicEventLog} = await runUpdate(); + + expect(cryptographicEventLog.log).to.have.length(2); + const updateEntry = cryptographicEventLog.log[1]; + expect(updateEntry.event).to.have.property('previousEventHash'); + expect(updateEntry.event.previousEventHash).to.match(/^z/); + expect(updateEntry).to.have.property('proof'); + expect(updateEntry.proof).to.be.an('array').with.length.at.least(1); + }); + + it('should include the new authentication key in the update event', + async () => { + const {cryptographicEventLog} = await runUpdate(); + + const updateEntry = cryptographicEventLog.log[1]; + expect(updateEntry.event.operation.type).to.equal('update'); + const didDoc = updateEntry.event.operation.data; + expect(didDoc).to.have.property('authentication'); + expect(didDoc.authentication).to.be.an('array').with.length.at.least(1); + }); + + it('should initialize a new array when verificationRelationship is absent', + async () => { + const {didDocument} = await create(); + + // 'capabilityInvocation' is not present on a freshly-created document + expect(didDocument.capabilityInvocation).to.be.undefined; + + const {keyPair: newKp, didDocument: updated} = await addVm({ + didDocument, verificationRelationship: 'capabilityInvocation' + }); + + expect(updated.capabilityInvocation).to.be.an('array').with.length(1); + expect(newKp).to.exist; + }); + + it('should throw MALFORMED_CEL_ERROR when adding an event to an empty log', + async () => { + const {heartbeatSecret, didDocument} = await create(); + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const updatedDoc = structuredClone(didDocument); + updatedDoc.heartbeat = [await computeHeartbeatHash(heartbeatSecret, 1)]; + const updateEvent = await createEvent({ + type: 'update', data: updatedDoc, signingKeyPair: hbKey0, + previousEventHash: undefined + }); + + let error; + try { + await addEvent({cel: {log: []}, event: updateEvent}); + } catch(e) { + error = e; + } + + expect(error).to.exist; + expect(error.name).to.equal('MALFORMED_CEL_ERROR'); + }); +}); diff --git a/tests/mocha/40-heartbeat.js b/tests/mocha/40-heartbeat.js new file mode 100644 index 0000000..340be33 --- /dev/null +++ b/tests/mocha/40-heartbeat.js @@ -0,0 +1,64 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + addEvent, create, createEvent, deriveHeartbeatKeyPair, getPreviousEventHash, + witness +} from '../../lib/index.js'; +import {computeHeartbeatHash, TEST_WITNESSES} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +async function runHeartbeat() { + const {heartbeatSecret, cryptographicEventLog} = await create(); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + // derive key 0 (currently in heartbeat[]) for signing this heartbeat event + const hbKeyPair = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [await computeHeartbeatHash(heartbeatSecret, 1)]}, + signingKeyPair: hbKeyPair, + previousEventHash + }); + await addEvent({cel: cryptographicEventLog, event: hbEvent}); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + return {cryptographicEventLog}; +} + +describe('heartbeat', function() { + this.timeout(120000); + + it('should produce a 2-event hash-linked CEL signed with a did:key', + async () => { + const {cryptographicEventLog} = await runHeartbeat(); + + expect(cryptographicEventLog.log).to.have.length(2); + const heartbeatEntry = cryptographicEventLog.log[1]; + expect(heartbeatEntry.event).to.have.property('previousEventHash'); + expect(heartbeatEntry.event.previousEventHash).to.match(/^z/); + const vm = heartbeatEntry.event.proof?.verificationMethod; + expect(vm).to.be.a('string').that.matches(/^did:key:/); + }); + + it('should carry a rotated heartbeat hash and a witness proof', + async () => { + const {cryptographicEventLog} = await runHeartbeat(); + + const heartbeatEntry = cryptographicEventLog.log[1]; + expect(heartbeatEntry.event.operation.type).to.equal('heartbeat'); + expect(heartbeatEntry).to.have.property('proof'); + expect(heartbeatEntry.proof).to.be.an('array').with.length.at.least(1); + + const createDoc = cryptographicEventLog.log[0].event.operation.data; + const hbDoc = heartbeatEntry.event.operation.data; + expect(hbDoc.heartbeat[0]).to.not.equal(createDoc.heartbeat[0]); + }); +}); diff --git a/tests/mocha/50-deactivate.js b/tests/mocha/50-deactivate.js new file mode 100644 index 0000000..b666ee4 --- /dev/null +++ b/tests/mocha/50-deactivate.js @@ -0,0 +1,79 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, + getPreviousEventHash, witness +} from '../../lib/index.js'; +import {computeHeartbeatHash, TEST_WITNESSES} from './helpers.js'; +import chai from 'chai'; + +const {expect} = chai; + +async function runDeactivate() { + const {heartbeatSecret, didDocument, cryptographicEventLog} = await create(); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const hbKey1 = await deriveHeartbeatKeyPair(heartbeatSecret, 1); + + const {didDocument: updatedDoc} = await addVm({ + didDocument, + verificationRelationship: 'authentication' + }); + // rotate heartbeat key 0→1 in the update data + updatedDoc.heartbeat = [await computeHeartbeatHash(heartbeatSecret, 1)]; + + const updatePreviousHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const updateEvent = await createEvent({ + type: 'update', + data: updatedDoc, + signingKeyPair: hbKey0, + previousEventHash: updatePreviousHash + }); + await addEvent({cel: cryptographicEventLog, event: updateEvent}); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const deactivatePreviousHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const deactivateEvent = await createEvent({ + type: 'deactivate', + data: undefined, + signingKeyPair: hbKey1, + previousEventHash: deactivatePreviousHash + }); + await addEvent({cel: cryptographicEventLog, event: deactivateEvent}); + + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + return {cryptographicEventLog}; +} + +describe('deactivate', function() { + this.timeout(120000); + + it('should produce a 3-event hash-linked CEL with witness proofs', + async () => { + const {cryptographicEventLog} = await runDeactivate(); + + expect(cryptographicEventLog.log).to.have.length(3); + for(let i = 1; i < cryptographicEventLog.log.length; i++) { + const entry = cryptographicEventLog.log[i]; + expect(entry.event).to.have.property('previousEventHash'); + expect(entry.event.previousEventHash).to.match(/^z/); + expect(entry.proof).to.be.an('array').with.length.at.least(1); + } + }); + + it('should have a deactivate event with no operation data', async () => { + const {cryptographicEventLog} = await runDeactivate(); + + const deactivateEntry = cryptographicEventLog.log[2]; + expect(deactivateEntry.event.operation).to.have.property( + 'type', 'deactivate'); + expect(deactivateEntry.event.operation.data).to.be.undefined; + }); +}); diff --git a/tests/mocha/60-save.js b/tests/mocha/60-save.js new file mode 100644 index 0000000..9f0b746 --- /dev/null +++ b/tests/mocha/60-save.js @@ -0,0 +1,403 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import { + addEvent, addVm, create, createEvent, deriveHeartbeatKeyPair, + getPreviousEventHash, loadFromFile, loadSecrets, saveSecrets, saveToFile, + setHeartbeatFrequency, witness +} from '../../lib/index.js'; +import { + computeHeartbeatHash, TEST_PASSWORD, TEST_WITNESS_DIDS, TEST_WITNESSES, +} from './helpers.js'; +import {mkdirSync, mkdtempSync, rmSync} from 'node:fs'; +import chai from 'chai'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; + +const {expect} = chai; + +describe('save', function() { + this.timeout(120000); + + let tmpDir; + let logsDir; + let secretsDir; + + before(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'didcel-test-')); + logsDir = join(tmpDir, 'logs'); + secretsDir = join(tmpDir, 'secrets'); + mkdirSync(logsDir, {recursive: true}); + }); + + after(() => { + rmSync(tmpDir, {recursive: true, force: true}); + }); + + describe('saveSecrets / loadSecrets', function() { + it('should round-trip key pairs and heartbeat secret', async () => { + const {heartbeatSecret, didDocument} = await create(); + const {keyPair} = await addVm( + {didDocument, verificationRelationship: 'assertionMethod'}); + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const secretKeys = { + authentication: [], + assertionMethod: [keyPair], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [], + heartbeat: heartbeatSecret + }; + + await saveSecrets( + {didIdentifier, secretKeys, password: TEST_PASSWORD, secretsDir}); + + const loaded = await loadSecrets( + {didIdentifier, password: TEST_PASSWORD, secretsDir}); + + expect(loaded.assertionMethod).to.have.length(1); + const exportedOriginal = + await keyPair.export({publicKey: true, includeContext: false}); + const exportedLoaded = + await loaded.assertionMethod[0].export( + {publicKey: true, includeContext: false}); + expect(exportedLoaded.publicKeyMultibase) + .to.equal(exportedOriginal.publicKeyMultibase); + + expect(loaded.heartbeat).to.be.instanceOf(Buffer).with.length(16); + expect(loaded.heartbeat.toString('hex')) + .to.equal(heartbeatSecret.toString('hex')); + }); + + it('should save secrets across multiple relationships', async () => { + const {heartbeatSecret, didDocument} = await create(); + const {keyPair} = await addVm( + {didDocument, verificationRelationship: 'assertionMethod'}); + const {keyPair: authKeyPair} = await addVm( + {didDocument, verificationRelationship: 'authentication'}); + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const secretKeys = { + authentication: [authKeyPair], + assertionMethod: [keyPair], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [], + heartbeat: heartbeatSecret + }; + + await saveSecrets( + {didIdentifier, secretKeys, password: TEST_PASSWORD, secretsDir}); + + const loaded = await loadSecrets( + {didIdentifier, password: TEST_PASSWORD, secretsDir}); + + expect(loaded.assertionMethod).to.have.length(1); + expect(loaded.authentication).to.have.length(1); + }); + + it('should fail to load secrets with wrong password', async () => { + const {heartbeatSecret, didDocument} = await create(); + const {keyPair} = await addVm( + {didDocument, verificationRelationship: 'assertionMethod'}); + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const secretKeys = { + authentication: [], + assertionMethod: [keyPair], + capabilityInvocation: [], + capabilityDelegation: [], + keyAgreement: [], + heartbeat: heartbeatSecret + }; + + await saveSecrets( + {didIdentifier, secretKeys, password: TEST_PASSWORD, secretsDir}); + + let error; + try { + await loadSecrets( + {didIdentifier, password: 'wrong-password', secretsDir}); + } catch(e) { + error = e; + } + expect(error).to.exist; + }); + }); + + describe('cel.loadFromFile / cel.read', function() { + // Build a trustedWitnesses list covering the entire test epoch. + // TEST_WITNESS_DIDS is populated by mock-witness.js start(). + function getTrustedWitnesses() { + return TEST_WITNESS_DIDS.map(id => ({ + id, + validFrom: '2000-01-01T00:00:00Z', + validUntil: '2099-01-01T00:00:00Z' + })); + } + + it('should save and load a valid CEL', async () => { + const {didDocument, cryptographicEventLog} = await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}.cel`); + saveToFile({filename: celPath, cel: cryptographicEventLog}); + + const {cel, valid, errors, didDocument: loadedDoc} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid, `errors: ${JSON.stringify(errors)}`).to.be.true; + expect(errors).to.have.length(0); + expect(cel.log).to.have.length(1); + expect(loadedDoc.id).to.equal(didDocument.id); + }); + + it('should load a multi-event CEL and validate all events', async () => { + const {heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const nextHash = await computeHeartbeatHash(heartbeatSecret, 1); + + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [nextHash]}, + signingKeyPair: hbKey0, + previousEventHash + }); + await addEvent({cel: cryptographicEventLog, event: hbEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}.cel`); + saveToFile({filename: celPath, cel: cryptographicEventLog}); + + const {valid, errors, cel} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid, `errors: ${JSON.stringify(errors)}`).to.be.true; + expect(errors).to.have.length(0); + expect(cel.log).to.have.length(2); + }); + + it('should resolve historical DID state using versionTime', async () => { + const {heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + // capture the witness timestamp of the create entry as the cutoff + const createWitnessTime = + cryptographicEventLog.log[0].proof[0].created; + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const nextHash = await computeHeartbeatHash(heartbeatSecret, 1); + + // add a heartbeat entry after a small delay + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [nextHash]}, + signingKeyPair: hbKey0, + previousEventHash + }); + await addEvent({cel: cryptographicEventLog, event: hbEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}-versiontime.cel`); + + // set the heartbeat's witness timestamp to 1 hour after the create + const snapshotted = JSON.parse(JSON.stringify(cryptographicEventLog)); + const laterTime = new Date( + new Date(createWitnessTime).getTime() + 60 * 60 * 1000).toISOString(); + snapshotted.log[1].proof[0].created = laterTime; + saveToFile({filename: celPath, cel: snapshotted}); + + // resolving at the create witness time should stop before the heartbeat + // entry (whose witness timestamp is 1 hour later), so the returned + // didDocument should match the original create-event document + const {valid, errors, didDocument: resolvedDoc} = await loadFromFile({ + filename: celPath, + trustedWitnesses: getTrustedWitnesses(), + versionTime: createWitnessTime + }); + + expect(valid, `errors: ${JSON.stringify(errors)}`).to.be.true; + expect(resolvedDoc.id).to.equal(didDocument.id); + }); + + it('should detect a heartbeatFrequency violation', async () => { + const {heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const nextHash = await computeHeartbeatHash(heartbeatSecret, 1); + + const previousEventHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [nextHash]}, + signingKeyPair: hbKey0, + previousEventHash + }); + await addEvent({cel: cryptographicEventLog, event: hbEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}-hb-violation.cel`); + + // backdate the first entry's witness timestamp to well beyond P10Y + const violated = JSON.parse(JSON.stringify(cryptographicEventLog)); + const oldDate = new Date(Date.now() - 4000 * 24 * 60 * 60 * 1000); + violated.log[0].proof[0].created = oldDate.toISOString(); + saveToFile({filename: celPath, cel: violated}); + + const {valid, errors} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid).to.be.false; + expect(errors.some(e => e.includes('heartbeatFrequency'))).to.be.true; + }); + + it('should enforce a tightened heartbeatFrequency after update', + async () => { + // entry 0: create (hbKey0 active) + const {heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + const hbKey1 = await deriveHeartbeatKeyPair(heartbeatSecret, 1); + const hbKey1Hash = await computeHeartbeatHash(heartbeatSecret, 1); + const hbKey2Hash = await computeHeartbeatHash(heartbeatSecret, 2); + + // entry 1: update heartbeatFrequency to P1D; rotate hbKey0→hbKey1 + const {didDocument: updatedDoc} = + setHeartbeatFrequency({didDocument, heartbeatFrequency: 'P1D'}); + updatedDoc.heartbeat = [hbKey1Hash]; + const updateHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const updateEvent = await createEvent({ + type: 'update', data: updatedDoc, + signingKeyPair: hbKey0, previousEventHash: updateHash + }); + await addEvent({cel: cryptographicEventLog, event: updateEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + // entry 2: heartbeat — gap from entry 1 to entry 2 will be backdated + // to 2 days, which exceeds the new P1D heartbeatFrequency + const hbHash = await getPreviousEventHash({cel: cryptographicEventLog}); + const hbEvent = await createEvent({ + type: 'heartbeat', + data: {heartbeat: [hbKey2Hash]}, + signingKeyPair: hbKey1, + previousEventHash: hbHash + }); + await addEvent({cel: cryptographicEventLog, event: hbEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = updatedDoc.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}-p1d-violation.cel`); + + // backdate entry 1's witness timestamp 2 days before entry 2's, so the + // gap between the witnessed update (entry 1) and heartbeat (entry 2) + // exceeds the P1D heartbeatFrequency now in effect + const violated = JSON.parse(JSON.stringify(cryptographicEventLog)); + const entry2Time = new Date( + violated.log[2].proof[0].created).getTime(); + const backdated = new Date(entry2Time - 2 * 24 * 60 * 60 * 1000); + violated.log[1].proof[0].created = backdated.toISOString(); + saveToFile({filename: celPath, cel: violated}); + + const {valid, errors} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid).to.be.false; + expect(errors.some(e => e.includes('heartbeatFrequency'))).to.be.true; + }); + + it('should detect tampering in a saved CEL', async () => { + const {didDocument, cryptographicEventLog} = await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}-tampered.cel`); + + // tamper with the DID document inside the event + const tampered = structuredClone(cryptographicEventLog); + tampered.log[0].event.operation.data.id = 'did:cel:zTAMPERED'; + saveToFile({filename: celPath, cel: tampered}); + + const {valid, errors} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid).to.be.false; + expect(errors).to.have.length.at.least(1); + }); + + it('should throw when loading from a non-existent file path', async () => { + const missingPath = join(logsDir, 'does-not-exist.cel'); + + let error; + try { + await loadFromFile( + {filename: missingPath, trustedWitnesses: getTrustedWitnesses()}); + } catch(e) { + error = e; + } + + expect(error).to.exist; + }); + + it('should reject any operation after a deactivate event', async () => { + const {heartbeatSecret, didDocument, cryptographicEventLog} = + await create(); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + const hbKey0 = await deriveHeartbeatKeyPair(heartbeatSecret, 0); + + // append a deactivate event (no rotation needed for deactivate) + const deactivateHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const deactivateEvent = await createEvent({ + type: 'deactivate', data: undefined, + signingKeyPair: hbKey0, previousEventHash: deactivateHash + }); + await addEvent({cel: cryptographicEventLog, event: deactivateEvent}); + await witness({cel: cryptographicEventLog, witnesses: TEST_WITNESSES}); + + // force a heartbeat entry directly into the log after deactivate, + // bypassing addEvent's deactivation guard to construct an invalid CEL + // that read() should reject + const postDeactivateHash = + await getPreviousEventHash({cel: cryptographicEventLog}); + const nextHash = await computeHeartbeatHash(heartbeatSecret, 1); + const heartbeatEvent = await createEvent({ + type: 'heartbeat', data: {heartbeat: [nextHash]}, + signingKeyPair: hbKey0, previousEventHash: postDeactivateHash + }); + cryptographicEventLog.log.push({event: heartbeatEvent}); + + const didIdentifier = didDocument.id.replace('did:cel:', ''); + const celPath = join(logsDir, `${didIdentifier}-post-deactivate.cel`); + saveToFile({filename: celPath, cel: cryptographicEventLog}); + + const {valid, errors} = + await loadFromFile( + {filename: celPath, trustedWitnesses: getTrustedWitnesses()}); + + expect(valid).to.be.false; + expect(errors.some(e => e.includes('after deactivation'))).to.be.true; + }); + }); +}); diff --git a/tests/mocha/helpers.js b/tests/mocha/helpers.js new file mode 100644 index 0000000..e0f1a7b --- /dev/null +++ b/tests/mocha/helpers.js @@ -0,0 +1,25 @@ +/*! + * Copyright (c) 2024-2026 Digital Bazaar, Inc. + */ +import {deriveHeartbeatKeyPair, sha3256Multibase} from '../../lib/index.js'; + +export const TEST_PASSWORD = 'test-password-for-automated-tests'; +// populated by mock-witness.js start() before tests run +export const TEST_WITNESSES = []; +// DID identifiers of the mock witnesses, used to build trustedWitnesses lists +export const TEST_WITNESS_DIDS = []; + +/** + * Returns the SHA3-256 multibase hash of the heartbeat did:key at `index`. + * This is the value stored in the DID document's `heartbeat[]` array. + * + * @param {Buffer|Uint8Array} heartbeatSecret - 16-byte HKDF master secret. + * @param {number} index - Key derivation index. + * @returns {Promise} Base58btc-encoded SHA3-256 multihash + * (`z`-prefixed). + */ +export async function computeHeartbeatHash(heartbeatSecret, index) { + const kp = await deriveHeartbeatKeyPair(heartbeatSecret, index); + const exported = await kp.export({publicKey: true, includeContext: false}); + return sha3256Multibase(`did:key:${exported.publicKeyMultibase}`); +} diff --git a/tests/mocha/mock-witness.js b/tests/mocha/mock-witness.js new file mode 100644 index 0000000..b01c08f --- /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 {TEST_WITNESS_DIDS, TEST_WITNESSES} from './helpers.js'; +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; + +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(); + const url = `http://127.0.0.1:${port}/witness`; + TEST_WITNESSES.push(url); + TEST_WITNESS_DIDS.push(didKeyId); +} + +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/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