ethr-did-resolver
Version:
Resolve DID documents for ethereum addresses and public keys
445 lines (420 loc) • 15.8 kB
text/typescript
import { BlockTag, encodeBase58, encodeBase64, toUtf8String } from 'ethers'
import { ConfigurationOptions, ConfiguredNetworks, configureResolverWithNetworks } from './configuration.js'
import {
DIDDocument,
DIDResolutionOptions,
DIDResolutionResult,
DIDResolver,
ParsedDID,
Resolvable,
Service,
VerificationMethod,
} from 'did-resolver'
import {
DIDAttributeChanged,
DIDDelegateChanged,
DIDOwnerChanged,
ERC1056Event,
Errors,
eventNames,
identifierMatcher,
interpretIdentifier,
legacyAlgoMap,
legacyAttrTypes,
LegacyVerificationMethod,
nullAddress,
strip0x,
verificationMethodTypes,
} from './helpers.js'
import { logDecoder } from './logParser.js'
export function getResolver(options: ConfigurationOptions): Record<string, DIDResolver> {
return new EthrDidResolver(options).build()
}
export class EthrDidResolver {
private contracts: ConfiguredNetworks
constructor(options: ConfigurationOptions) {
this.contracts = configureResolverWithNetworks(options)
}
/**
* Returns the block number with the previous change to a particular address (DID)
*
* @param address - the address (DID) to check for changes
* @param networkId - the EVM network to check
* @param blockTag - the block tag to use for the query (default: 'latest')
*/
async previousChange(address: string, networkId: string, blockTag?: BlockTag): Promise<bigint> {
return await this.contracts[networkId].changed(address, { blockTag })
}
async getBlockMetadata(blockHeight: number, networkId: string): Promise<{ height: string; isoDate: string }> {
const networkContract = this.contracts[networkId]
if (!networkContract) throw new Error(`No contract configured for network ${networkId}`)
if (!networkContract.runner) throw new Error(`No runner configured for contract with network ${networkId}`)
if (!networkContract.runner.provider)
throw new Error(`No provider configured for runner in contract with network ${networkId}`)
const block = await networkContract.runner.provider.getBlock(blockHeight)
if (!block) throw new Error(`Block at height ${blockHeight} not found`)
return {
height: block.number.toString(),
isoDate: new Date(block.timestamp * 1000).toISOString().replace('.000', ''),
}
}
async changeLog(
identity: string,
networkId: string,
blockTag: BlockTag = 'latest'
): Promise<{ address: string; history: ERC1056Event[]; controllerKey?: string; chainId: bigint }> {
const contract = this.contracts[networkId]
if (!contract) throw new Error(`No contract configured for network ${networkId}`)
if (!contract.runner) throw new Error(`No runner configured for contract with network ${networkId}`)
if (!contract.runner.provider)
throw new Error(`No provider configured for runner in contract with network ${networkId}`)
const provider = contract.runner.provider
const hexChainId = networkId.startsWith('0x') ? networkId : undefined
//TODO: this can be used to check if the configuration is ok
const chainId = hexChainId ? BigInt(hexChainId) : (await provider.getNetwork()).chainId
const history: ERC1056Event[] = []
const { address, publicKey } = interpretIdentifier(identity)
const controllerKey = publicKey
let previousChange: bigint | null = await this.previousChange(address, networkId, blockTag)
while (previousChange) {
const blockNumber = previousChange
const logs = await provider.getLogs({
address: await contract.getAddress(), // networks[networkId].registryAddress,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
topics: [null as any, `0x000000000000000000000000${address.slice(2)}`],
fromBlock: previousChange,
toBlock: previousChange,
})
const events: ERC1056Event[] = logDecoder(contract, logs)
events.reverse()
previousChange = null
for (const event of events) {
history.unshift(event)
if (event.previousChange < blockNumber) {
previousChange = event.previousChange
}
}
}
return { address, history, controllerKey, chainId }
}
wrapDidDocument(
did: string,
address: string,
controllerKey: string | undefined,
history: ERC1056Event[],
chainId: bigint,
blockHeight: string | number,
now: bigint
): { didDocument: DIDDocument; deactivated: boolean; versionId: number; nextVersionId: number } {
const baseDIDDocument: DIDDocument = {
id: did,
verificationMethod: [],
authentication: [],
assertionMethod: [],
}
let controller = address
const authentication = [`${did}#controller`]
const assertionMethod = [`${did}#controller`]
let versionId = 0
let nextVersionId = Number.POSITIVE_INFINITY
let deactivated = false
let delegateCount = 0
let serviceCount = 0
let endpoint = ''
const auth: Record<string, string> = {}
const keyAgreementRefs: Record<string, string> = {}
const signingRefs: Record<string, string> = {}
const pks: Record<string, VerificationMethod> = {}
const services: Record<string, Service> = {}
if (typeof blockHeight === 'string') {
// latest
blockHeight = -1
}
for (const event of history) {
if (blockHeight !== -1 && event.blockNumber > blockHeight) {
if (nextVersionId > event.blockNumber) {
nextVersionId = event.blockNumber
}
continue
} else {
if (versionId < event.blockNumber) {
versionId = event.blockNumber
}
}
const validTo = event.validTo || BigInt(0)
const eventIndex = `${event._eventName}-${
(event as DIDDelegateChanged).delegateType || (event as DIDAttributeChanged).name
}-${(event as DIDDelegateChanged).delegate || (event as DIDAttributeChanged).value}`
if (validTo && validTo >= now) {
if (event._eventName === eventNames.DIDDelegateChanged) {
const currentEvent = event as DIDDelegateChanged
delegateCount++
const delegateType = currentEvent.delegateType //conversion from bytes32 is done in logParser
switch (delegateType) {
case 'sigAuth':
auth[eventIndex] = `${did}#delegate-${delegateCount}`
signingRefs[eventIndex] = `${did}#delegate-${delegateCount}`
// eslint-disable-next-line no-fallthrough
case 'veriKey':
pks[eventIndex] = {
id: `${did}#delegate-${delegateCount}`,
type: verificationMethodTypes.EcdsaSecp256k1RecoveryMethod2020,
controller: did,
blockchainAccountId: `eip155:${chainId}:${currentEvent.delegate}`,
}
signingRefs[eventIndex] = `${did}#delegate-${delegateCount}`
break
}
} else if (event._eventName === eventNames.DIDAttributeChanged) {
const currentEvent = event as DIDAttributeChanged
const name = currentEvent.name //conversion from bytes32 is done in logParser
const match = name.match(/^did\/(pub|svc)\/(\w+)(\/(\w+))?(\/(\w+))?$/)
if (match) {
const section = match[1]
const algorithm = match[2]
const type = legacyAttrTypes[match[4]] || match[4]
const encoding = match[6]
switch (section) {
case 'pub': {
delegateCount++
const pk: LegacyVerificationMethod = {
id: `${did}#delegate-${delegateCount}`,
type: `${algorithm}${type}`,
controller: did,
}
pk.type = legacyAlgoMap[pk.type] || algorithm
switch (encoding) {
case null:
case undefined:
case 'hex':
pk.publicKeyHex = strip0x(currentEvent.value)
break
case 'base64':
pk.publicKeyBase64 = encodeBase64(currentEvent.value)
break
case 'base58':
pk.publicKeyBase58 = encodeBase58(currentEvent.value)
break
case 'pem':
pk.publicKeyPem = toUtf8String(currentEvent.value)
break
default:
pk.value = strip0x(currentEvent.value)
}
pks[eventIndex] = pk
if (match[4] === 'sigAuth') {
auth[eventIndex] = pk.id
signingRefs[eventIndex] = pk.id
} else if (match[4] === 'enc') {
keyAgreementRefs[eventIndex] = pk.id
} else {
signingRefs[eventIndex] = pk.id
}
break
}
case 'svc': {
serviceCount++
const encodedService = toUtf8String(currentEvent.value)
try {
endpoint = JSON.parse(encodedService)
} catch {
endpoint = encodedService
}
services[eventIndex] = {
id: `${did}#service-${serviceCount}`,
type: algorithm,
serviceEndpoint: endpoint,
}
break
}
}
}
}
} else if (event._eventName === eventNames.DIDOwnerChanged) {
const currentEvent = event as DIDOwnerChanged
controller = currentEvent.owner
if (currentEvent.owner === nullAddress) {
deactivated = true
break
}
} else {
if (
event._eventName === eventNames.DIDDelegateChanged ||
(event._eventName === eventNames.DIDAttributeChanged &&
(event as DIDAttributeChanged).name.match(/^did\/pub\//))
) {
delegateCount++
} else if (
event._eventName === eventNames.DIDAttributeChanged &&
(event as DIDAttributeChanged).name.match(/^did\/svc\//)
) {
serviceCount++
}
delete auth[eventIndex]
delete signingRefs[eventIndex]
delete pks[eventIndex]
delete services[eventIndex]
}
}
const publicKeys: VerificationMethod[] = [
{
id: `${did}#controller`,
type: verificationMethodTypes.EcdsaSecp256k1RecoveryMethod2020,
controller: did,
blockchainAccountId: `eip155:${chainId}:${controller}`,
},
]
if (controllerKey && controller == address) {
publicKeys.push({
id: `${did}#controllerKey`,
type: verificationMethodTypes.EcdsaSecp256k1VerificationKey2019,
controller: did,
publicKeyHex: strip0x(controllerKey),
})
authentication.push(`${did}#controllerKey`)
assertionMethod.push(`${did}#controllerKey`)
}
const didDocument: DIDDocument = {
...baseDIDDocument,
verificationMethod: publicKeys.concat(Object.values(pks)),
authentication: authentication.concat(Object.values(auth)),
assertionMethod: assertionMethod.concat(Object.values(signingRefs)),
}
if (Object.values(services).length > 0) {
didDocument.service = Object.values(services)
}
if (Object.values(keyAgreementRefs).length > 0) {
didDocument.keyAgreement = Object.values(keyAgreementRefs)
}
return deactivated
? {
didDocument: baseDIDDocument,
deactivated,
versionId,
nextVersionId,
}
: { didDocument, deactivated, versionId, nextVersionId }
}
async resolve(
did: string,
parsed: ParsedDID,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_unused: Resolvable,
options: DIDResolutionOptions
): Promise<DIDResolutionResult> {
let ldContext = {}
if (options.accept === 'application/did+json') {
ldContext = {}
} else if (options.accept === 'application/did+ld+json' || typeof options.accept !== 'string') {
ldContext = {
'@context': [
'https://www.w3.org/ns/did/v1',
// defines EcdsaSecp256k1RecoveryMethod2020 & blockchainAccountId
'https://w3id.org/security/suites/secp256k1recovery-2020/v2',
// defines publicKeyHex & EcdsaSecp256k1VerificationKey2019; v2 does not define publicKeyHex
'https://w3id.org/security/v3-unstable',
],
}
} else {
return {
didResolutionMetadata: {
error: Errors.unsupportedFormat,
message: `The DID resolver does not support the requested 'accept' format: ${options.accept}`,
},
didDocumentMetadata: {},
didDocument: null,
}
}
const fullId = parsed.id.match(identifierMatcher)
if (!fullId) {
return {
didResolutionMetadata: {
error: Errors.invalidDid,
message: `Not a valid did:ethr: ${parsed.id}`,
},
didDocumentMetadata: {},
didDocument: null,
}
}
const id = fullId[2]
const networkId = !fullId[1] ? 'mainnet' : fullId[1].slice(0, -1)
let blockTag: string | number = options.blockTag || 'latest'
if (typeof parsed.query === 'string') {
const qParams = new URLSearchParams(parsed.query)
blockTag = qParams.get('versionId') ?? blockTag
const parsedBlockTag = Number.parseInt(blockTag as string)
if (!Number.isNaN(parsedBlockTag)) {
blockTag = parsedBlockTag
} else {
blockTag = 'latest'
}
}
if (!this.contracts[networkId]) {
return {
didResolutionMetadata: {
error: Errors.unknownNetwork,
message: `The DID resolver does not have a configuration for network: ${networkId}`,
},
didDocumentMetadata: {},
didDocument: null,
}
}
let now = BigInt(Math.floor(new Date().getTime() / 1000))
if (typeof blockTag === 'number') {
const block = await this.getBlockMetadata(blockTag, networkId)
now = BigInt(Date.parse(block.isoDate) / 1000)
} else {
// 'latest'
}
const { address, history, controllerKey, chainId } = await this.changeLog(id, networkId, 'latest')
try {
const { didDocument, deactivated, versionId, nextVersionId } = this.wrapDidDocument(
did,
address,
controllerKey,
history,
chainId,
blockTag,
now
)
const status = deactivated ? { deactivated: true } : {}
let versionMeta = {}
let versionMetaNext = {}
if (versionId !== 0) {
const block = await this.getBlockMetadata(versionId, networkId)
versionMeta = {
versionId: block.height,
updated: block.isoDate,
}
}
if (nextVersionId !== Number.POSITIVE_INFINITY) {
const block = await this.getBlockMetadata(nextVersionId, networkId)
versionMetaNext = {
nextVersionId: block.height,
nextUpdate: block.isoDate,
}
}
return {
didDocumentMetadata: { ...status, ...versionMeta, ...versionMetaNext },
didResolutionMetadata: { contentType: options.accept ?? 'application/did+ld+json' },
didDocument: {
...didDocument,
...ldContext,
},
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
return {
didResolutionMetadata: {
error: Errors.notFound,
message: e.toString(), // This is not in spec, nut may be helpful
},
didDocumentMetadata: {},
didDocument: null,
}
}
}
build(): Record<string, DIDResolver> {
return { ethr: this.resolve.bind(this) }
}
}