Skip to content

Commit

Permalink
Agent resolver refreshes did store and cache (#914)
Browse files Browse the repository at this point in the history
This PR adds `update` functionality to `DidApi` which will store an updated did document and (optionally) publish the updated document if the did is a `did:dht` method, the resolution cache is pre-populated with the updated document.

Additionally the `AgentDidResolverCache` now updates the DID Store with any newly resolved DID to make sure the locate store is in sync wit the resolved DID.

The `BearerDid` import method now checks if a key already exists in the key manager before attempting to import it when importing an `portableDid`.
  • Loading branch information
LiranCohen authored Oct 11, 2024
1 parent aaf4b4a commit bd1cb00
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 30 deletions.
9 changes: 9 additions & 0 deletions .changeset/brave-cameras-reply.md
Original file line number Diff line number Diff line change
@@ -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
29 changes: 26 additions & 3 deletions packages/agent/src/agent-did-resolver-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DidResolutionResult, DidResolverCache, DidResolverCacheLevel, DidResolverCacheLevelParams } from '@web5/dids';
import { Web5PlatformAgent } from './types/agent.js';
import { logger } from '@web5/common';


/**
Expand Down Expand Up @@ -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);
Expand Down
55 changes: 54 additions & 1 deletion packages/agent/src/did-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ 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';
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',
Expand Down Expand Up @@ -256,6 +257,58 @@ export class AgentDidApi<TKeyManager extends AgentKeyManager = AgentKeyManager>
return verificationMethod;
}

public async update({ tenant, portableDid, publish = true }: {
tenant?: string;
portableDid: PortableDid;
publish?: boolean;
}): Promise<BearerDid> {

// 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;
Expand Down
31 changes: 25 additions & 6 deletions packages/agent/src/store-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -26,6 +26,7 @@ export type DataStoreSetParams<TStoreObject> = DataStoreTenantParams & {
id: string;
data: TStoreObject;
preventDuplicates?: boolean;
updateExisting?: boolean;
useCache?: boolean;
}

Expand Down Expand Up @@ -137,7 +138,7 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = 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<TStoreObject>
): Promise<void> {
// Determine the tenant identifier (DID) for the set operation.
Expand All @@ -146,15 +147,26 @@ export class DwnDataStore<TStoreObject extends Record<string, any> = 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) {
throw new Error(`${this.name}: Import failed due to duplicate entry for: ${id}`);
}
}


// Convert the store object to a byte array, which will be the data payload of the DWN record.
const dataBytes = Convert.object(data).toUint8Array();

Expand Down Expand Up @@ -340,12 +352,19 @@ export class InMemoryDataStore<TStoreObject extends Record<string, any> = Jwk> i
return result;
}

public async set({ id, data, tenant, agent, preventDuplicates }: DataStoreSetParams<TStoreObject>): Promise<void> {
public async set({ id, data, tenant, agent, preventDuplicates, updateExisting }: DataStoreSetParams<TStoreObject>): Promise<void> {
// 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}`);
Expand Down
60 changes: 45 additions & 15 deletions packages/agent/tests/agent-did-resolver-cach.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),

Expand All @@ -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 () => {
Expand All @@ -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);
Expand Down
Loading

0 comments on commit bd1cb00

Please sign in to comment.