diff --git a/.changeset/brave-cameras-reply.md b/.changeset/brave-cameras-reply.md new file mode 100644 index 000000000..49b67b0a6 --- /dev/null +++ b/.changeset/brave-cameras-reply.md @@ -0,0 +1,9 @@ +--- +"@web5/agent": minor +"@web5/dids": minor +"@web5/identity-agent": minor +"@web5/proxy-agent": minor +"@web5/user-agent": minor +--- + +Ability to Update a DID diff --git a/packages/agent/src/agent-did-resolver-cache.ts b/packages/agent/src/agent-did-resolver-cache.ts index ecf35b76b..5bb635fdb 100644 --- a/packages/agent/src/agent-did-resolver-cache.ts +++ b/packages/agent/src/agent-did-resolver-cache.ts @@ -1,5 +1,6 @@ import { DidResolutionResult, DidResolverCache, DidResolverCacheLevel, DidResolverCacheLevelParams } from '@web5/dids'; import { Web5PlatformAgent } from './types/agent.js'; +import { logger } from '@web5/common'; /** @@ -47,11 +48,33 @@ export class AgentDidResolverCache extends DidResolverCacheLevel implements DidR const cachedResult = JSON.parse(str); if (!this._resolving.has(did) && Date.now() >= cachedResult.ttlMillis) { this._resolving.set(did, true); - if (this.agent.agentDid.uri === did || 'undefined' !== typeof await this.agent.identity.get({ didUri: did })) { + + // if a DID is stored in the DID Store, then we don't want to evict it from the cache until we have a successful resolution + // upon a successful resolution, we will update both the storage and the cache with the newly resolved Document. + const storedDid = await this.agent.did.get({ didUri: did, tenant: this.agent.agentDid.uri }); + if ('undefined' !== typeof storedDid) { try { const result = await this.agent.did.resolve(did); - if (!result.didResolutionMetadata.error) { - this.set(did, result); + + // if the resolution was successful, update the stored DID with the new Document + if (!result.didResolutionMetadata.error && result.didDocument) { + + const portableDid = { + ...storedDid, + document : result.didDocument, + metadata : result.didDocumentMetadata, + }; + + try { + // this will throw an error if the DID is not managed by the agent, or there is no difference between the stored and resolved DID + // We don't publish the DID in this case, as it was received by the resolver. + await this.agent.did.update({ portableDid, tenant: this.agent.agentDid.uri, publish: false }); + } catch(error: any) { + // if the error is not due to no changes detected, log the error + if (error.message && !error.message.includes('No changes detected, update aborted')) { + logger.error(`Error updating DID: ${error.message}`); + } + } } } finally { this._resolving.delete(did); diff --git a/packages/agent/src/did-api.ts b/packages/agent/src/did-api.ts index 91abb5807..10ea60777 100644 --- a/packages/agent/src/did-api.ts +++ b/packages/agent/src/did-api.ts @@ -11,7 +11,7 @@ import type { DidResolverCache, } from '@web5/dids'; -import { BearerDid, Did, UniversalResolver } from '@web5/dids'; +import { BearerDid, Did, DidDht, UniversalResolver } from '@web5/dids'; import type { AgentDataStore } from './store-data.js'; import type { AgentKeyManager } from './types/key-manager.js'; @@ -19,6 +19,7 @@ import type { ResponseStatus, Web5PlatformAgent } from './types/agent.js'; import { InMemoryDidStore } from './store-did.js'; import { AgentDidResolverCache } from './agent-did-resolver-cache.js'; +import { canonicalize } from '@web5/crypto'; export enum DidInterface { Create = 'Create', @@ -256,6 +257,58 @@ export class AgentDidApi return verificationMethod; } + public async update({ tenant, portableDid, publish = true }: { + tenant?: string; + portableDid: PortableDid; + publish?: boolean; + }): Promise { + + // Check if the DID exists in the store. + const existingDid = await this.get({ didUri: portableDid.uri, tenant: tenant ?? portableDid.uri }); + if (!existingDid) { + throw new Error(`AgentDidApi: Could not update, DID not found: ${portableDid.uri}`); + } + + // If the document has not changed, abort the update. + if (canonicalize(portableDid.document) === canonicalize(existingDid.document)) { + throw new Error('AgentDidApi: No changes detected, update aborted'); + } + + // If private keys are present in the PortableDid, import the key material into the Agent's key + // manager. Validate that the key material for every verification method in the DID document is + // present in the key manager. If no keys are present, this will fail. + // NOTE: We currently do not delete the previous keys from the document. + // TODO: Add support for deleting the keys no longer present in the document. + const bearerDid = await BearerDid.import({ keyManager: this.agent.keyManager, portableDid }); + + // Only the DID URI, document, and metadata are stored in the Agent's DID store. + const { uri, document, metadata } = bearerDid; + const portableDidWithoutKeys: PortableDid = { uri, document, metadata }; + + // pre-populate the resolution cache with the document and metadata + await this.cache.set(uri, { didDocument: document, didResolutionMetadata: { }, didDocumentMetadata: metadata }); + + await this._store.set({ + id : uri, + data : portableDidWithoutKeys, + agent : this.agent, + tenant : tenant ?? uri, + updateExisting : true, + useCache : true + }); + + if (publish) { + const parsedDid = Did.parse(uri); + // currently only supporting DHT as a publishable method. + // TODO: abstract this into the didMethod class so that other publishable methods can be supported. + if (parsedDid && parsedDid.method === 'dht') { + await DidDht.publish({ did: bearerDid }); + } + } + + return bearerDid; + } + public async import({ portableDid, tenant }: { portableDid: PortableDid; tenant?: string; diff --git a/packages/agent/src/store-data.ts b/packages/agent/src/store-data.ts index cc2b0535b..c4fbea41e 100644 --- a/packages/agent/src/store-data.ts +++ b/packages/agent/src/store-data.ts @@ -7,7 +7,7 @@ import type { Web5PlatformAgent } from './types/agent.js'; import { TENANT_SEPARATOR } from './utils-internal.js'; import { getDataStoreTenant } from './utils-internal.js'; -import { DwnInterface } from './types/dwn.js'; +import { DwnInterface, DwnMessageParams } from './types/dwn.js'; import { ProtocolDefinition } from '@tbd54566975/dwn-sdk-js'; export type DataStoreTenantParams = { @@ -26,6 +26,7 @@ export type DataStoreSetParams = DataStoreTenantParams & { id: string; data: TStoreObject; preventDuplicates?: boolean; + updateExisting?: boolean; useCache?: boolean; } @@ -137,7 +138,7 @@ export class DwnDataStore = Jwk> implem return storedRecords; } - public async set({ id, data, tenant, agent, preventDuplicates = true, useCache = false }: + public async set({ id, data, tenant, agent, preventDuplicates = true, updateExisting = false, useCache = false }: DataStoreSetParams ): Promise { // Determine the tenant identifier (DID) for the set operation. @@ -146,8 +147,18 @@ export class DwnDataStore = Jwk> implem // initialize the storage protocol if not already done await this.initialize({ tenant: tenantDid, agent }); - // If enabled, check if a record with the given `id` is already present in the store. - if (preventDuplicates) { + const messageParams: DwnMessageParams[DwnInterface.RecordsWrite] = { ...this._recordProperties }; + + if (updateExisting) { + // Look up the DWN record ID of the object in the store with the given `id`. + const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); + if (!matchingRecordId) { + throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); + } + + // set the recordId in the messageParams to update the existing record + messageParams.recordId = matchingRecordId; + } else if (preventDuplicates) { // Look up the DWN record ID of the object in the store with the given `id`. const matchingRecordId = await this.lookupRecordId({ id, tenantDid, agent }); if (matchingRecordId) { @@ -155,6 +166,7 @@ export class DwnDataStore = Jwk> implem } } + // Convert the store object to a byte array, which will be the data payload of the DWN record. const dataBytes = Convert.object(data).toUint8Array(); @@ -340,12 +352,19 @@ export class InMemoryDataStore = Jwk> i return result; } - public async set({ id, data, tenant, agent, preventDuplicates }: DataStoreSetParams): Promise { + public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams): Promise { // Determine the tenant identifier (DID) for the set operation. const tenantDid = await getDataStoreTenant({ agent, tenant, didUri: id }); // If enabled, check if a record with the given `id` is already present in the store. - if (preventDuplicates) { + if (updateExisting) { + // Look up the DWN record ID of the object in the store with the given `id`. + if (!this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`)) { + throw new Error(`${this.name}: Update failed due to missing entry for: ${id}`); + } + + // set the recordId in the messageParams to update the existing record + } else if (preventDuplicates) { const duplicateFound = this.store.has(`${tenantDid}${TENANT_SEPARATOR}${id}`); if (duplicateFound) { throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`); diff --git a/packages/agent/tests/agent-did-resolver-cach.spec.ts b/packages/agent/tests/agent-did-resolver-cach.spec.ts index 37b7536b0..ecf75de76 100644 --- a/packages/agent/tests/agent-did-resolver-cach.spec.ts +++ b/packages/agent/tests/agent-did-resolver-cach.spec.ts @@ -4,8 +4,8 @@ import { TestAgent } from './utils/test-agent.js'; import sinon from 'sinon'; import { expect } from 'chai'; -import { DidJwk } from '@web5/dids'; -import { BearerIdentity } from '../src/bearer-identity.js'; +import { BearerDid, DidJwk } from '@web5/dids'; +import { logger } from '@web5/common'; describe('AgentDidResolverCache', () => { let resolverCache: AgentDidResolverCache; @@ -61,11 +61,10 @@ describe('AgentDidResolverCache', () => { }); it('should not call resolve if the DID is not the agent DID or exists as an identity in the agent', async () => { - const did = await DidJwk.create({}); + const did = await DidJwk.create(); const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } })); - const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve'); + const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri); const nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves(); - sinon.stub(testHarness.agent.identity, 'get').resolves(undefined); await resolverCache.get(did.uri), @@ -77,21 +76,52 @@ describe('AgentDidResolverCache', () => { expect(nextTickSpy.callCount).to.equal(1); }); - it('should resolve if the DID is managed by the agent', async () => { - const did = await DidJwk.create({}); + it('should resolve and update if the DID is managed by the agent', async () => { + const did = await DidJwk.create(); + const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } })); - const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve'); - const nextTickSpy = sinon.stub(resolverCache['cache'], 'nextTick').resolves(); - sinon.stub(testHarness.agent.identity, 'get').resolves(new BearerIdentity({ - metadata: { name: 'Some Name', uri: did.uri, tenant: did.uri }, - did, + const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri); + sinon.stub(resolverCache['cache'], 'nextTick').resolves(); + const didApiStub = sinon.stub(testHarness.agent.did, 'get'); + const updateSpy = sinon.stub(testHarness.agent.did, 'update').resolves(); + didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({ + uri : did.uri, + document : { id: did.uri }, + metadata : { }, + keyManager : testHarness.agent.keyManager })); await resolverCache.get(did.uri), // get should be called once, and we also resolve the DId as it's returned by the identity.get method - expect(getStub.callCount).to.equal(1); - expect(resolveSpy.callCount).to.equal(1); + expect(getStub.callCount).to.equal(1, 'get'); + expect(resolveSpy.callCount).to.equal(1, 'resolve'); + expect(updateSpy.callCount).to.equal(1, 'update'); + }); + + it('should log an error if an update is attempted and fails', async () => { + const did = await DidJwk.create(); + + const getStub = sinon.stub(resolverCache['cache'], 'get').resolves(JSON.stringify({ ttlMillis: Date.now() - 1000, value: { didDocument: { id: did.uri } } })); + const resolveSpy = sinon.spy(testHarness.agent.did, 'resolve').withArgs(did.uri); + sinon.stub(resolverCache['cache'], 'nextTick').resolves(); + const didApiStub = sinon.stub(testHarness.agent.did, 'get'); + const updateSpy = sinon.stub(testHarness.agent.did, 'update').rejects(new Error('Some Error')); + const consoleErrorSpy = sinon.stub(logger, 'error'); + didApiStub.withArgs({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }).resolves(new BearerDid({ + uri : did.uri, + document : { id: did.uri }, + metadata : { }, + keyManager : testHarness.agent.keyManager + })); + + await resolverCache.get(did.uri), + + // get should be called once, and we also resolve the DId as it's returned by the identity.get method + expect(getStub.callCount).to.equal(1, 'get'); + expect(resolveSpy.callCount).to.equal(1, 'resolve'); + expect(updateSpy.callCount).to.equal(1, 'update'); + expect(consoleErrorSpy.callCount).to.equal(1, 'console.error'); }); it('does not cache notFound records', async () => { @@ -107,7 +137,7 @@ describe('AgentDidResolverCache', () => { it('throws if the error is anything other than a notFound error', async () => { const did = testHarness.agent.agentDid.uri; - const getStub = sinon.stub(resolverCache['cache'], 'get').rejects(new Error('Some Error')); + sinon.stub(resolverCache['cache'], 'get').rejects(new Error('Some Error')); try { await resolverCache.get(did); diff --git a/packages/agent/tests/did-api.spec.ts b/packages/agent/tests/did-api.spec.ts index d7f5148d2..53037af6c 100644 --- a/packages/agent/tests/did-api.spec.ts +++ b/packages/agent/tests/did-api.spec.ts @@ -1,6 +1,6 @@ import sinon from 'sinon'; import { expect } from 'chai'; -import { BearerDid, DidJwk } from '@web5/dids'; +import { BearerDid, DidDht, DidJwk, PortableDid } from '@web5/dids'; import type { Web5PlatformAgent } from '../src/types/agent.js'; @@ -300,7 +300,25 @@ describe('AgentDidApi', () => { }); describe('export()', () => { - xit('should be implemented'); + it('exports a DID to a PortableDid object', async () => { + // Generate a new DID. + const did = await DidJwk.create(); + const portableDid = await did.export(); + + // import the DID + await testHarness.agent.did.import({ portableDid, tenant: testHarness.agent.agentDid.uri }); + + // Export the DID to a PortableDid object. + const exportedDid = await testHarness.agent.did.export({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(exportedDid).to.have.property('uri', did.uri); + expect(exportedDid).to.have.property('document'); + expect(exportedDid).to.have.property('metadata'); + + // Verify the exported document. + expect(exportedDid.document).to.deep.equal(portableDid.document); + }); }); describe('import()', () => { @@ -481,7 +499,206 @@ describe('AgentDidApi', () => { }); describe('update()', () => { - xit('should be implemented'); + beforeEach(async () => { + // Generate a new DID. + const mockedPortableDid: PortableDid = { + uri : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + document : { + '@context' : 'https://www.w3.org/ns/did/v1', + id : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + verificationMethod : [ + { + id : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0', + type : 'JsonWebKey', + controller : 'did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo', + publicKeyJwk : { + crv : 'Ed25519', + kty : 'OKP', + x : 'VYKm2SCIV9Vz3BRy-v5R9GHz3EOJCPvZ1_gP1e3XiB0', + kid : 'cyvOypa6k-4ffsRWcza37s5XVOh1kO9ICUeo1ZxHVM8', + alg : 'EdDSA', + }, + }, + ], + authentication : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + assertionMethod : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + capabilityDelegation : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + capabilityInvocation : ['did:dht:ksbkpsjytbm7kh6hnt3xi91t6to98zndtrrxzsqz9y87m5qztyqo#0'], + }, + metadata: { + }, + privateKeys: [ + { + crv : 'Ed25519', + d : 'hdSIwbQwVD-fNOVEgt-k3mMl44Ip1iPi58Ex6VDGxqY', + kty : 'OKP', + x : 'VYKm2SCIV9Vz3BRy-v5R9GHz3EOJCPvZ1_gP1e3XiB0', + kid : 'cyvOypa6k-4ffsRWcza37s5XVOh1kO9ICUeo1ZxHVM8', + alg : 'EdDSA', + }, + ], + }; + + const mockedBearerDid = await DidDht.import({ portableDid: mockedPortableDid, keyManager: testHarness.agent.keyManager }); + sinon.stub(DidDht, 'create').resolves(mockedBearerDid); + }); + + it('updates a DID in the store', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'jwk', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + }); + + it('updates a DID DHT and publishes it by default', async () => { + const publishSpy = sinon.spy(DidDht, 'publish'); + + const did = await testHarness.agent.did.create({ method: 'dht', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID, publishes by default + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + + // Verify publish was called + expect(publishSpy.called).to.be.true; + }); + + it('updates a DID DHT and does not publish it if publish is false', async () => { + const publishSpy = sinon.spy(DidDht, 'publish'); + + const did = await testHarness.agent.did.create({ method: 'dht', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri, publish: false }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: testHarness.agent.agentDid.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + + // Verify publish was called + expect(publishSpy.called).to.be.false; + }); + + it('updates a DID under the tenant of the updated DID if tenant is not provided ', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'dht'}); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid }); + + // get the updated DID + const updatedDid = await testHarness.agent.did.get({ didUri: did.uri, tenant: did.uri }); + + // Verify the result. + expect(updatedDid).to.have.property('uri', did.uri); + expect(updatedDid).to.have.property('document'); + expect(updatedDid).to.have.property('metadata'); + + // Verify the updated document. + expect(updatedDid!.document).to.deep.equal(updateDid.document); + }); + + it('throws if DID does not exist in the store', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'dht'}); + const portableDid = await did.export(); + + const updateDid = { + ...portableDid, + uri : 'did:example:123', // change the uri to a different DID + document : { + ...portableDid.document, + service: [{ id: 'service1', type: 'example', serviceEndpoint: 'https://example.com' }] + } + }; + + try { + // Update the DID. + await testHarness.agent.did.update({ portableDid: updateDid, tenant: testHarness.agent.agentDid.uri }); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.include('AgentDidApi: Could not update, DID not found'); + } + }); + + it('throws if the DID document is not updated', async () => { + // Generate a new DID. + const did = await testHarness.agent.did.create({ method: 'jwk', tenant: testHarness.agent.agentDid.uri }); + const portableDid = await did.export(); + + try { + // Update the DID. + await testHarness.agent.did.update({ portableDid, tenant: testHarness.agent.agentDid.uri }); + expect.fail('Expected an error to be thrown'); + } catch(error: any) { + expect(error.message).to.include('AgentDidApi: No changes detected, update aborted'); + } + }); }); }); }); diff --git a/packages/agent/tests/local-key-manager.spec.ts b/packages/agent/tests/local-key-manager.spec.ts index 8e9b64163..6b53232a3 100644 --- a/packages/agent/tests/local-key-manager.spec.ts +++ b/packages/agent/tests/local-key-manager.spec.ts @@ -1,9 +1,10 @@ import type { Jwk } from '@web5/crypto'; import type { BearerDid } from '@web5/dids'; +import sinon from 'sinon'; import { expect } from 'chai'; import { Convert } from '@web5/common'; -import { CryptoUtils } from '@web5/crypto'; +import { CryptoUtils, Ed25519 } from '@web5/crypto'; import type { Web5PlatformAgent } from '../src/types/agent.js'; @@ -106,6 +107,21 @@ describe('LocalKeyManager', () => { }); }); + describe('importKey()', () => { + it('imports a key and returns a key URI', async () => { + // generate a key and import it + const key = await Ed25519.generateKey(); + const keyUri = await testHarness.agent.keyManager.importKey({ key }); + + // fetch the key using the keyUri + const importedKey = await testHarness.agent.keyManager.exportKey({ keyUri }); + + // validate the key + expect(importedKey).to.exist; + expect(importedKey).to.deep.equal(key); + }); + }); + describe('exportKey()', () => { it('exports a private key as a JWK', async () => { const keyUri = await testHarness.agent.keyManager.generateKey({ algorithm: 'secp256k1' }); diff --git a/packages/agent/tests/store-data.spec.ts b/packages/agent/tests/store-data.spec.ts index 8340a541b..ac562dac3 100644 --- a/packages/agent/tests/store-data.spec.ts +++ b/packages/agent/tests/store-data.spec.ts @@ -702,6 +702,58 @@ describe('AgentDataStore', () => { expect(error.message).to.include('Failed to install protocol: 500 - Internal Server Error'); } }); + + describe('updateExisting', () => { + it('updates an existing record', async () => { + // Create and import a DID. + let bearerDid = await DidJwk.create(); + const importedDid = await testHarness.agent.did.import({ + portableDid : await bearerDid.export(), + tenant : testHarness.agent.agentDid.uri + }); + + const portableDid = await importedDid.export(); + + // update did document's service + const updatedDid = { + ...portableDid, + document: { + ...portableDid.document, + service: [{ id: 'test-service', type: 'test-type', serviceEndpoint: 'test-endpoint' }] + } + }; + + // Update the DID in the store. + await testStore.set({ + id : importedDid.uri, + data : updatedDid, + agent : testHarness.agent, + updateExisting : true, + tenant : testHarness.agent.agentDid.uri + }); + + // Verify the DID is in the store. + const storedDid = await testStore.get({ id: importedDid.uri, agent: testHarness.agent, tenant: testHarness.agent.agentDid.uri }); + expect(storedDid!.uri).to.equal(updatedDid.uri); + expect(storedDid!.document).to.deep.equal(updatedDid.document); + }); + + it('throws an error if the record does not exist', async () => { + const did = await DidJwk.create(); + const portableDid = await did.export(); + try { + await testStore.set({ + id : portableDid.uri, + data : portableDid, + agent : testHarness.agent, + updateExisting : true + }); + expect.fail('Expected an error to be thrown'); + } catch (error: any) { + expect(error.message).to.include(`${TestStore.name}: Update failed due to missing entry for: ${portableDid.uri}`); + } + }); + }); }); }); }); diff --git a/packages/dids/src/bearer-did.ts b/packages/dids/src/bearer-did.ts index 120f94186..cdecee834 100644 --- a/packages/dids/src/bearer-did.ts +++ b/packages/dids/src/bearer-did.ts @@ -240,6 +240,7 @@ export class BearerDid { keyManager?: CryptoApi & KeyImporterExporter; portableDid: PortableDid; }): Promise { + // Get all verification methods from the given DID document, including embedded methods. const verificationMethods = getVerificationMethods({ didDocument: portableDid.document }); @@ -250,7 +251,13 @@ export class BearerDid { // If given, import the private key material into the key manager. for (let key of portableDid.privateKeys ?? []) { - await keyManager.importKey({ key }); + + // confirm th key does not already exist before importing it to avoid failures from the key manager + const keyUri = await keyManager.getKeyUri({ key }); + const keyExists = await keyManager.getPublicKey({ keyUri }).then(() => true).catch(() => false); + if (!keyExists) { + await keyManager.importKey({ key }); + } } // Validate that the key material for every verification method in the DID document is present diff --git a/packages/dids/tests/bearer-did.spec.ts b/packages/dids/tests/bearer-did.spec.ts index db0790bb8..7657b7504 100644 --- a/packages/dids/tests/bearer-did.spec.ts +++ b/packages/dids/tests/bearer-did.spec.ts @@ -447,5 +447,23 @@ describe('BearerDid', () => { expect(error.message).to.include('Key not found'); } }); + + it('does not attempt to import a key that is already in the key manager', async () => { + + // create a key manager + const keyManager = new LocalKeyManager(); + + // Import one of the private keys into the key manager + const privateKey = portableDid.privateKeys![0]; + await keyManager.importKey({ key: privateKey }); + + // spy on the importKey method + const importKeySpy = sinon.spy(keyManager, 'importKey'); + + // attempt to import the BearerDid with the key manager + const did = await BearerDid.import({ portableDid, keyManager }); + expect(did.uri).to.equal(portableDid.uri); + expect(importKeySpy.calledOnce).to.be.false; + }); }); }); \ No newline at end of file