From 9159d7a0631c039cce09f558ae2a52de27baff1f Mon Sep 17 00:00:00 2001 From: David McFadzean Date: Mon, 9 Dec 2024 15:22:12 -0500 Subject: [PATCH] feat: Re-enable hash check on operations (#463) * Added opcid (operation CID) to mdip on resolution * Fix for json-cache * Renamed anchorSeed to generateDID * Fixed opcid in v1 * Replaced prev (doc hash) with cid (op hash) in operations * Improved coverage * Refactored importEvent * Improved opcid tests * Update MDIP scheme doc * Cleaned up test code * Renamed opcid and cid to opid and previd respectively --- doc/mdip/scheme.md | 12 +- packages/gatekeeper/src/db-json-cache.js | 10 +- packages/gatekeeper/src/gatekeeper-lib.js | 52 ++++++-- packages/keymaster/src/keymaster-lib.js | 24 ++-- tests/gatekeeper.test.js | 149 +++++++++++++++++++--- 5 files changed, 193 insertions(+), 54 deletions(-) diff --git a/doc/mdip/scheme.md b/doc/mdip/scheme.md index e1906807..c00565a8 100644 --- a/doc/mdip/scheme.md +++ b/doc/mdip/scheme.md @@ -146,7 +146,7 @@ A DID Update is a change to any of the documents associated with the DID. To ini 1. `didDocumentMetadata` the document's metadata 1. `didDocumentData` the document's data 1. `mdip` the MDIP protocol spec - 1. `prev` the sha256 hash of the canonicalized JSON of the previous version's doc + 1. `previd` the CID of the previous operation 1. Sign the JSON with the private key of the controller of the DID 1. Submit the operation to the MDIP node. For example, with a REST API, post the operation to the MDIP node's endpoint to update DIDs (e.g. `/api/v1/did/`) @@ -191,7 +191,7 @@ Example update to rotate keys for an agent DID: "version": 1 } }, - "prev": "fb794984f44fe869a75fade8a7bf31ce0f3f46a3eaded4e286769c62f5d9a9ff", + "previd": "z3v8Auaa5U9xP6TRzobvzZE7j6N8nkatxW1UuWiay5xrbAR5D9e", "signature": { "signer": "did:mdip:test:z3v8AuadvRQErtPapNx3ncdUJpPc5dBDGTXXiRxsaH2N8Lj2KzL", "signed": "2024-03-25T14:57:26.343Z", @@ -203,7 +203,7 @@ Example update to rotate keys for an agent DID: Upon receiving the operation the MDIP node must: 1. Verify the signature is valid for the controller of the DID. -1. Verify the previous hash. +1. Verify the previd is identical to the latest version's operation CID. 1. Record the operation on the DID specified registry (or forward the request to a trusted node that supports the specified registry). For registries such as BTC with non-trivial transaction costs, it is expected that update operations will be placed in a queue, then registered periodically in a batch in order to balance costs and latency of updates. If the registry has trivial transaction costs, the update operation may be distributed individually and immediately. MDIP defers this tradeoff between cost, speed, and security to the node operators. @@ -217,7 +217,7 @@ To revoke a DID, the MDIP client must sign and submit a `delete` operation to th 1. Create a operation object with these fields in any order: 1. `type` must be "delete" 1. `did` specifies the DID to be deleted - 1. `prev` the sha256 hash of the canonicalized JSON of the previous version's doc + 1. `previd` the CID of the previous operation 1. Sign the JSON with the private key of the controller of the DID 1. Submit the operation to the MDIP node. For example, with a REST API, post the operation using the `DELETE` method to the MDIP node's endpoint to update DIDs (e.g. `/api/v1/did/`) @@ -227,7 +227,7 @@ Example deletion operation: { "type": "delete", "did": "did:mdip:z3v8AuagQPwk6WhAjauVgkFCBJfHJBVBmNAYEhDNMBEXEmWQrHr", - "prev": "9f7f0a67b729248c966bb8945cb80320713aa1de42021c88ca849a4ca029f8d7", + "previd": "z3v8AuaWLbUPpU31mCazznLYy6JtTWmgx9QFsDVveDPDU8Na1sJ", "signature": { "signer": "did:mdip:z3v8Auad6fdVkSZE4khWmMwgTjpoMtv82fiT7c56ivNBdjzeMS2", "created": "2024-02-05T20:00:54.171Z", @@ -239,7 +239,7 @@ Example deletion operation: Upon receiving the operation the MDIP node must: 1. Verify the signature is valid for the controller of the DID. -1. Verify the previous hash. +1. Verify the previd is identical to the latest version's operation CID. 1. Record the operation on the DID specified registry (or forward the request to a trusted node that supports the specified registry). After revocation is confirmed on the DID's registry, resolving the DID will result in response like this: diff --git a/packages/gatekeeper/src/db-json-cache.js b/packages/gatekeeper/src/db-json-cache.js index 5c8e443d..1cfa222f 100644 --- a/packages/gatekeeper/src/db-json-cache.js +++ b/packages/gatekeeper/src/db-json-cache.js @@ -89,21 +89,21 @@ export async function addEvent(did, event) { } export async function getEvents(did) { + let events = []; + try { const db = loadDb(); const suffix = did.split(':').pop(); const updates = db.dids[suffix]; if (updates && updates.length > 0) { - return updates; - } - else { - return []; + events = updates; } } catch { - return []; } + + return JSON.parse(JSON.stringify(events)); } export async function setEvents(did, events) { diff --git a/packages/gatekeeper/src/gatekeeper-lib.js b/packages/gatekeeper/src/gatekeeper-lib.js index a6105290..8a1f7538 100644 --- a/packages/gatekeeper/src/gatekeeper-lib.js +++ b/packages/gatekeeper/src/gatekeeper-lib.js @@ -166,10 +166,12 @@ export async function resetDb() { verifiedDIDs = {}; } -export async function anchorSeed(seed) { - //console.time('>>ipfs.add'); - const cid = await ipfs.add(JSON.parse(canonicalize(seed))); - //console.timeEnd('>>ipfs.add'); +export async function generateCID(operation) { + return ipfs.add(JSON.parse(canonicalize(operation))); +} + +export async function generateDID(operation) { + const cid = await generateCID(operation); return `${config.didPrefix}:${cid}`; } @@ -338,7 +340,7 @@ export async function createDID(operation) { const valid = await verifyCreateOperation(operation); if (valid) { - const did = await anchorSeed(operation); + const did = await generateDID(operation); const ops = await exportDID(did); // Check to see if we already have this DID in the db @@ -385,7 +387,7 @@ export async function generateDoc(anchor) { return {}; } - const did = await anchorSeed(anchor); + const did = await generateDID(anchor); if (anchor.mdip.type === 'agent') { // TBD support different key types? @@ -448,9 +450,8 @@ export async function resolveDID(did, options = {}) { const anchor = events[0]; let doc = await generateDoc(anchor.operation); - let mdip = doc?.mdip; - if (atTime && new Date(mdip.created) > new Date(atTime)) { + if (atTime && new Date(doc.mdip.created) > new Date(atTime)) { // TBD What to return if DID was created after specified time? } @@ -461,6 +462,8 @@ export async function resolveDID(did, options = {}) { doc.didDocumentMetadata.confirmed = confirmed; for (const { time, operation, registry, blockchain } of events) { + const opid = await generateCID(operation); + if (operation.type === 'create') { if (verify) { const valid = await verifyCreateOperation(operation); @@ -469,6 +472,7 @@ export async function resolveDID(did, options = {}) { throw new InvalidOperationError('signature'); } } + doc.mdip.opid = opid; continue; } @@ -480,7 +484,7 @@ export async function resolveDID(did, options = {}) { break; } - confirmed = confirmed && mdip.registry === registry; + confirmed = confirmed && doc.mdip.registry === registry; if (confirm && !confirmed) { break; @@ -492,22 +496,24 @@ export async function resolveDID(did, options = {}) { if (!valid) { throw new InvalidOperationError('signature'); } + + // TEMP during did:test, operation.previd is optional + if (operation.previd && operation.previd !== doc.mdip.opid) { + throw new InvalidOperationError('previd'); + } } if (operation.type === 'update') { // Increment version version += 1; - // Maintain mdip metadata across versions - mdip = doc.mdip; - // TBD if registry change in operation.doc.didDocumentMetadata.mdip, // fetch updates from new registry and search for same operation doc = operation.doc; doc.didDocumentMetadata.updated = time; doc.didDocumentMetadata.version = version; doc.didDocumentMetadata.confirmed = confirmed; - doc.mdip = mdip; + doc.mdip.opid = opid; if (blockchain) { doc.mdip.registration = blockchain; @@ -522,6 +528,14 @@ export async function resolveDID(did, options = {}) { doc.didDocumentMetadata.deactivated = true; doc.didDocumentMetadata.deleted = time; doc.didDocumentMetadata.confirmed = confirmed; + doc.mdip.opid = opid; + + if (blockchain) { + doc.mdip.registration = blockchain; + } + else { + delete doc.mdip.registration; + } } else { if (verify) { @@ -643,7 +657,7 @@ async function importEvent(event) { event.did = event.operation.did; } else { - event.did = await anchorSeed(event.operation); + event.did = await generateDID(event.operation); } } @@ -675,6 +689,16 @@ async function importEvent(event) { const ok = await verifyOperation(event.operation); if (ok) { + // TEMP during did:test, operation.previd is optional + if (currentEvents.length > 0 && event.operation.previd) { + const lastEvent = currentEvents[currentEvents.length - 1]; + const opid = await generateCID(lastEvent.operation); + + if (opid !== event.operation.previd) { + throw new InvalidOperationError('previd'); + } + } + await db.addEvent(did, event); return true; } diff --git a/packages/keymaster/src/keymaster-lib.js b/packages/keymaster/src/keymaster-lib.js index 2d37827f..aeeae3a8 100644 --- a/packages/keymaster/src/keymaster-lib.js +++ b/packages/keymaster/src/keymaster-lib.js @@ -317,13 +317,13 @@ async function updateSeedBank(doc) { const keypair = await hdKeyPair(); const did = doc.didDocument.id; const current = await gatekeeper.resolveDID(did); - const prev = cipher.hashJSON(current); + const previd = current.mdip.opid; const operation = { type: "update", - did: did, - doc: doc, - prev: prev, + did, + previd, + doc, }; const msgHash = cipher.hashJSON(operation); @@ -655,13 +655,13 @@ export async function verifySignature(obj) { export async function updateDID(doc) { const did = doc.didDocument.id; const current = await resolveDID(did); - const prev = cipher.hashJSON(current); + const previd = current.mdip.opid; const operation = { type: "update", - did: did, - doc: doc, - prev: prev, + did, + previd, + doc, }; const controller = current.didDocument.controller || current.didDocument.id; @@ -671,12 +671,12 @@ export async function updateDID(doc) { export async function revokeDID(did) { const current = await resolveDID(did); - const prev = cipher.hashJSON(current); + const previd = current.mdip.opid; const operation = { type: "delete", - did: did, - prev: prev, + did, + previd, }; const controller = current.didDocument.controller || current.didDocument.id; @@ -1237,7 +1237,7 @@ async function findMatchingCredential(credential) { } export async function createResponse(challengeDID, options = {}) { - let { retries = 0, delay = 1000} = options; + let { retries = 0, delay = 1000 } = options; if (!options.registry) { options.registry = ephemeralRegistry; diff --git a/tests/gatekeeper.test.js b/tests/gatekeeper.test.js index a7bf5ed8..b3ceef40 100644 --- a/tests/gatekeeper.test.js +++ b/tests/gatekeeper.test.js @@ -53,13 +53,13 @@ async function createAgentOp(keypair, version = 1, registry = 'local') { async function createUpdateOp(keypair, did, doc) { const current = await gatekeeper.resolveDID(did); - const prev = cipher.hashJSON(current); + const previd = current.mdip.opid; const operation = { type: "update", did, + previd, doc, - prev, }; const msgHash = cipher.hashJSON(operation); @@ -78,12 +78,12 @@ async function createUpdateOp(keypair, did, doc) { async function createDeleteOp(keypair, did) { const current = await gatekeeper.resolveDID(did); - const prev = cipher.hashJSON(current); + const previd = current.mdip.opid; const operation = { type: "delete", did, - prev + previd, }; const msgHash = cipher.hashJSON(operation); @@ -147,7 +147,7 @@ describe('start', () => { }); }); -describe('anchorSeed', () => { +describe('generateDID', () => { afterEach(() => { mockFs.restore(); @@ -163,7 +163,7 @@ describe('anchorSeed', () => { registry: "mockRegistry" } }; - const did = await gatekeeper.anchorSeed(mockTxn); + const did = await gatekeeper.generateDID(mockTxn); expect(did.startsWith('did:test:')).toBe(true); }); @@ -178,8 +178,8 @@ describe('anchorSeed', () => { registry: "mockRegistry" } }; - const did1 = await gatekeeper.anchorSeed(mockTxn); - const did2 = await gatekeeper.anchorSeed(mockTxn); + const did1 = await gatekeeper.generateDID(mockTxn); + const did2 = await gatekeeper.generateDID(mockTxn); expect(did1 === did2).toBe(true); }); @@ -509,6 +509,7 @@ describe('resolveDID', () => { const keypair = cipher.generateRandomJwk(); const agentOp = await createAgentOp(keypair); + const opid = await gatekeeper.generateCID(agentOp); const did = await gatekeeper.createDID(agentOp); const doc = await gatekeeper.resolveDID(did); const expected = { @@ -538,7 +539,10 @@ describe('resolveDID', () => { version: 1, confirmed: true, }, - mdip: agentOp.mdip, + mdip: { + ...agentOp.mdip, + opid, + } }; expect(doc).toStrictEqual(expected); @@ -554,6 +558,7 @@ describe('resolveDID', () => { const doc = await gatekeeper.resolveDID(did); doc.didDocumentData = { mock: 1 }; const updateOp = await createUpdateOp(keypair, did, doc); + const opid = await gatekeeper.generateCID(updateOp); const ok = await gatekeeper.updateDID(updateOp); const updatedDoc = await gatekeeper.resolveDID(did); const expected = { @@ -582,7 +587,10 @@ describe('resolveDID', () => { version: 2, confirmed: true, }, - mdip: agentOp.mdip, + mdip: { + ...agentOp.mdip, + opid, + } }; expect(ok).toBe(true); @@ -664,6 +672,7 @@ describe('resolveDID', () => { const update = await gatekeeper.resolveDID(did); update.didDocumentData = { mock: 1 }; const updateOp = await createUpdateOp(keypair, did, update); + const opid = await gatekeeper.generateCID(updateOp); const ok = await gatekeeper.updateDID(updateOp); const confirmedDoc = await gatekeeper.resolveDID(did, { confirm: true }); @@ -693,7 +702,10 @@ describe('resolveDID', () => { version: 2, confirmed: true, }, - mdip: agentOp.mdip, + mdip: { + ...agentOp.mdip, + opid, + } }; expect(ok).toBe(true); @@ -711,6 +723,7 @@ describe('resolveDID', () => { const update = await gatekeeper.resolveDID(did); update.didDocumentData = { mock: 1 }; const updateOp = await createUpdateOp(keypair, did, update); + const opid = await gatekeeper.generateCID(updateOp); const ok = await gatekeeper.updateDID(updateOp); const verifiedDoc = await gatekeeper.resolveDID(did, { verify: true }); @@ -740,7 +753,10 @@ describe('resolveDID', () => { version: 2, confirmed: true, }, - mdip: agentOp.mdip, + mdip: { + ...agentOp.mdip, + opid, + } }; expect(ok).toBe(true); @@ -757,6 +773,7 @@ describe('resolveDID', () => { const update = await gatekeeper.resolveDID(did); update.didDocumentData = { mock: 1 }; const updateOp = await createUpdateOp(keypair, did, update); + const opid = await gatekeeper.generateCID(updateOp); const ok = await gatekeeper.updateDID(updateOp); const updatedDoc = await gatekeeper.resolveDID(did, { confirm: false }); const expected = { @@ -785,7 +802,10 @@ describe('resolveDID', () => { version: 2, confirmed: false, }, - mdip: agentOp.mdip, + mdip: { + ...agentOp.mdip, + opid, + } }; expect(ok).toBe(true); @@ -875,6 +895,7 @@ describe('resolveDID', () => { const agentOp = await createAgentOp(keypair); const agent = await gatekeeper.createDID(agentOp); const assetOp = await createAssetOp(agent, keypair); + const opid = await gatekeeper.generateCID(assetOp); const did = await gatekeeper.createDID(assetOp); const doc = await gatekeeper.resolveDID(did); const expected = { @@ -892,7 +913,10 @@ describe('resolveDID', () => { version: 1, confirmed: true, }, - mdip: assetOp.mdip, + mdip: { + ...assetOp.mdip, + opid, + } }; expect(doc).toStrictEqual(expected); @@ -973,6 +997,78 @@ describe('resolveDID', () => { expect(error.message).toBe(InvalidDIDError.type); } }); + + it('should throw an exception on invalid signature in create op', async () => { + mockFs({}); + + const keypair = cipher.generateRandomJwk(); + const agentOp = await createAgentOp(keypair); + const did = await gatekeeper.createDID(agentOp); + + const events = await db_json.getEvents(did); + // changing anything in the op will invalidate the signature + events[0].operation.did = 'mock'; + await db_json.setEvents(did, events); + + try { + await gatekeeper.resolveDID(did, { verify: true }); + throw new ExpectedExceptionError(); + } catch (error) { + expect(error.message).toBe('Invalid operation: signature'); + } + }); + + it('should throw an exception on invalid signature in update op', async () => { + mockFs({}); + + const keypair = cipher.generateRandomJwk(); + const agentOp = await createAgentOp(keypair); + const did = await gatekeeper.createDID(agentOp); + const doc = await gatekeeper.resolveDID(did); + doc.didDocumentData = { mock: 1 }; + const updateOp = await createUpdateOp(keypair, did, doc); + await gatekeeper.updateDID(updateOp); + + const events = await db_json.getEvents(did); + // changing anything in the op will invalidate the signature + events[1].operation.did = 'mock'; + await db_json.setEvents(did, events); + + try { + await gatekeeper.resolveDID(did, { verify: true }); + throw new ExpectedExceptionError(); + } catch (error) { + expect(error.message).toBe('Invalid operation: signature'); + } + }); + + it('should throw an exception on invalid operation previd in update op', async () => { + mockFs({}); + + const keypair = cipher.generateRandomJwk(); + const agentOp = await createAgentOp(keypair); + const did = await gatekeeper.createDID(agentOp); + const doc1 = await gatekeeper.resolveDID(did); + doc1.didDocumentData = { mock: 1 }; + const updateOp1 = await createUpdateOp(keypair, did, doc1); + await gatekeeper.updateDID(updateOp1); + const doc2 = await gatekeeper.resolveDID(did); + doc2.didDocumentData = { mock: 2 }; + const updateOp2 = await createUpdateOp(keypair, did, doc2); + await gatekeeper.updateDID(updateOp2); + + const events = await db_json.getEvents(did); + // if we swap update events the sigs will be valid but the previd will be invalid + [events[1], events[2]] = [events[2], events[1]]; + await db_json.setEvents(did, events); + + try { + await gatekeeper.resolveDID(did, { verify: true }); + throw new ExpectedExceptionError(); + } catch (error) { + expect(error.message).toBe('Invalid operation: previd'); + } + }); }); describe('updateDID', () => { @@ -990,10 +1086,12 @@ describe('updateDID', () => { const doc = await gatekeeper.resolveDID(did); doc.didDocumentData = { mock: 1 }; const updateOp = await createUpdateOp(keypair, did, doc); + const opid = await gatekeeper.generateCID(updateOp); const ok = await gatekeeper.updateDID(updateOp); const updatedDoc = await gatekeeper.resolveDID(did); doc.didDocumentMetadata.updated = expect.any(String); doc.didDocumentMetadata.version = 2; + doc.mdip.opid = opid; expect(ok).toBe(true); expect(updatedDoc).toStrictEqual(doc); @@ -1048,6 +1146,24 @@ describe('updateDID', () => { expect(error.message).toBe('Invalid operation: signature'); } }); + + it('should verify DID that has been updated multiple times', async () => { + mockFs({}); + + const keypair = cipher.generateRandomJwk(); + const agentOp = await createAgentOp(keypair); + const did = await gatekeeper.createDID(agentOp); + const doc = await gatekeeper.resolveDID(did); + + for (let i = 0; i < 10; i++) { + doc.didDocumentData = { mock: i }; + const updateOp = await createUpdateOp(keypair, did, doc); + await gatekeeper.updateDID(updateOp); + } + + const doc2 = await gatekeeper.resolveDID(did, { verify: true }); + expect(doc2.didDocumentMetadata.version).toBe(11); + }); }); describe('exportDID', () => { @@ -1860,11 +1976,10 @@ describe('processEvents', () => { await gatekeeper.updateDID(updateOp); } - const ops = await gatekeeper.exportDID(did); - + const events = await gatekeeper.exportDID(did); await gatekeeper.resetDb(); + const { queued, rejected } = await gatekeeper.importBatch(events); - const { queued, rejected } = await gatekeeper.importBatch(ops); expect(queued).toBe(N + 1); expect(rejected).toBe(0);