Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Encode Hackathon: Support for did:web #19

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
188 changes: 188 additions & 0 deletions src/services/common/did/did-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { DIDResolutionResult, DIDResolver } from "did-resolver";
import { DID, DIDMethod, DIDWithKeys } from "./did";
import { KeyUtils, KEY_ALG } from "../../../utils";
import { getResolver } from "key-did-resolver";
import { JsonRpcProvider, Provider } from "@ethersproject/providers";
import { DIDMethodFailureError } from "../../../errors";
import { ethers } from 'ethers'
import { ProviderConfigs } from "./did-ethr";
import { Wallet } from "@ethersproject/wallet";
import axios from "axios";

export class WebDIDMethod implements DIDMethod {
/**
* did:web DID document using an ethereum address
*/
name = 'web';
domain: string;
providerConfigs: ProviderConfigs;
web3Provider: Provider;

constructor(domain: string, providerConfigs: ProviderConfigs) {
this.domain = domain;
this.providerConfigs = providerConfigs;
this.web3Provider = providerConfigs.provider ? providerConfigs.provider : new JsonRpcProvider(providerConfigs.rpcUrl);
}

/**
* Create request will create a Web DID document that will contain ES256K key.
*
* @returns a `Promise` that resolves to {@link DIDWithKeys}
*/
async create(): Promise<DIDWithKeys> {
const account = await ethers.Wallet.createRandom();
const privateKey = account.privateKey
const publicKey = KeyUtils.privateKeyToPublicKey(privateKey)

const id = `did:web:${this.domain}`;
const didDoc = {
"@context": [
"https://w3.org/ns/did/v1",
],
id,
"authentication": [
`${id}#${publicKey}`
],
};

return {
did: didDoc.id,
keyPair: {
algorithm: KEY_ALG.ES256K,
publicKey,
privateKey
}
}
}

/**
* From did:ethr
* Creates a DID given a private key
* Used when an ES256K keypair has already been generated and is going to be used as a DID
*
* @param privateKey - private key to be used in creation of a did:ethr DID
* @returns a `Promise` that resolves to {@link DIDWithKeys}
* Throws `DIDMethodFailureError` if private key is not in hex format
*/
async generateFromPrivateKey(privateKey: string | Uint8Array): Promise<DIDWithKeys> {
if (!KeyUtils.isHexPrivateKey(privateKey)) {
throw new DIDMethodFailureError('new public key not in hex format')
}
const publicKey = KeyUtils.privateKeyToPublicKey(privateKey as string)
const address = new Wallet(privateKey as string, this.web3Provider).address
const did = `did:web:${this.providerConfigs.name}:${address}`
const identity: DIDWithKeys = {
did,
keyPair: {
algorithm: KEY_ALG.ES256K,
publicKey,
privateKey
}
}
return identity;
}

/**
* Resolves a DID using the resolver from web-did-resolver to a {@link DIDResolutionResult}
* that contains the DIDDocument and associated Metadata
*
* Uses axios to resolve the DID
*
* @param did - DID to be resolved to its DIDDocument
* @returns a `Promise` that resolves to `DIDResolutionResult` defined in did-resolver
* Throws `DIDMethodFailureError` if resolution failed
*/
async resolve(did: DID ): Promise<DIDResolutionResult> {
try{
const url = new URL(did.replace("did:web:", "https://"));
let path = url.pathname;
if (path.length > 1 && path.includes("/")) {
path = path.replace(new RegExp("/" + "$"), "");
} else if (path.length === 1 && path.includes("/")) {
path = path.concat(".well-known");
}
const didPath = `https://${url.host}${path}`.concat("/did.json");

const response = await axios.get(didPath);
if(response.status===200) {
return response.data;
}else {
throw new DIDMethodFailureError(`DID Resolution failed for ${did}, ${response.data}`)
}
}catch(error) {
let msg = error.message;
if(error.response) {
msg = error.response.data;
}
throw new DIDMethodFailureError(`DID Resolution failed for ${did}, ${msg}`)

}
}

/**
* did:key does not support update
*/
async update(_did: DIDWithKeys, _publicKey: string | Uint8Array): Promise<boolean> {
throw new DIDMethodFailureError('Updates on did:web must be done by the organisation.')
}

/**
* did:key does not support deactivate
*/
async deactivate(_did: DIDWithKeys): Promise<boolean> {
throw new DIDMethodFailureError('Delete on did:web must be done by the organisation.')
}

/**
* Since did:key cannot be updated or deactivated, the status will always be active
*
* @param did - DID to check status of
* @returns a `Promise` that always resolves to true if DID is in correct format
* Throws `DIDMethodFailureError` otherwise
*/
async isActive(did: DID): Promise<boolean> {
try{
const didResult = await this.resolve(did)
return !didResult.didDocumentMetadata.deactivated
}catch (error) {
return false;
}
}

/**
* Helper function to return the Identifier from a did:key string
*
* @param did - DID string
* @returns the Identifier section of the DID
* Throws `DIDMethodFailureError` if format check fails
*/
getIdentifier(did: DID): string {
if(!this.checkFormat(did)) {
throw new DIDMethodFailureError('DID format incorrect')
}
return `${did.substring(did.indexOf(':', did.indexOf(':') + 1) + 1)}`;
}

/**
* Helper function to check format of a did:key
*
* Correct format is did:key:{alphanumeric identifier of 48 characters}
*
* @param did - DID string
* @returns true if format check passes
*/
checkFormat(did: DID): boolean {
const keyMatcher = /(did:key:)([a-zA-Z0-9]{48})$/
return keyMatcher.test(did as string)
}

/**
* Getter method for did:key Resolver from key-did-resolver
*
* @returns type that is input to new {@link Resolver} from did-resolver
*/
getDIDResolver(): Record<string, DIDResolver> {
return getResolver()
}

}
136 changes: 136 additions & 0 deletions tests/unit/did/did-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { Resolver } from "did-resolver";
import { randomBytes } from "crypto";
import { DIDMethodFailureError } from "../../../src/errors";
import { getSupportedResolvers } from "../../../src/services/common";
import { KEY_ALG, KeyUtils } from "../../../src/utils";
import { WebDIDMethod } from "../../../src/services/common/did/did-web";


describe('did:web utilities', () => {
let didResolver: Resolver
let webDidMethod: WebDIDMethod

const SAMPLE_DID = {
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/secp256k1recovery-2020/v2"],
"id": "did:web:mattr.global",
"verificationMethod": [{
"id": "did:web:mattr.global#0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9",
"type": "EcdsaSecp256k1RecoveryMethod2020",
"controller": "did:web:mattr.global",
"blockchainAccountId": "eip155:1:0x89a932207c485f85226d86f7cd486a89a24fcc12"
}],
"authentication": [
"did:web:mattr.global#0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9"
],
keyPair: {
algorithm: KEY_ALG.ES256K,
publicKey: ' 0456b642b139f7771e53f280efbfef62994fd9ecab806eb082b4883df5802fcbbdcbd5cd378372407a78fc86bff619b28c0c19104dc18d886c8e4914a33a8bee1b',
privateKey: '13f38a1233fae4e85fa3bc76110b8fefcd07ecf75f1393218e67e49024ffab3f'
}
}

const DID_DOC = {
didDocumentMetadata: {},
didResolutionMetadata: { contentType: 'application/did+ld+json' },
didDocument: {
}
}

beforeAll (async () => {
webDidMethod = new WebDIDMethod("mattr.global", {
name: 'maticmum',
rpcUrl: 'https://rpc-mumbai.maticvigil.com/',
registry: "0x41D788c9c5D335362D713152F407692c5EEAfAae"})

didResolver = getSupportedResolvers([webDidMethod]);
})

it('Successfully create did:web', async () => {
const res = await webDidMethod.create();
expect(res.did).toContain("did:web:mattr.global");
expect(KeyUtils.isHexPrivateKey(res.keyPair.privateKey)).toEqual(true)
expect(KeyUtils.isHexPublicKey(res.keyPair.publicKey)).toBeTruthy()
expect(res.keyPair.algorithm).toEqual(KEY_ALG.ES256K)

const doc = await didResolver.resolve(res.did)
expect(doc.didResolutionMetadata.error).toBeFalsy()
})

it('Successfully create did:web from private key', async () => {
const res = await webDidMethod.generateFromPrivateKey(SAMPLE_DID.keyPair.privateKey)
expect(res).toBeDefined()
expect(res.did).toBeDefined()
expect(res.did).toEqual(SAMPLE_DID.id)
expect(res.keyPair.privateKey).toBeDefined()
expect(KeyUtils.isHexPrivateKey(res.keyPair.privateKey)).toBeTruthy()
expect(res.keyPair.privateKey).toEqual(SAMPLE_DID.keyPair.privateKey)
expect(res.keyPair.publicKey).toBeDefined()
expect(KeyUtils.isHexPublicKey(res.keyPair.publicKey)).toBeTruthy()
expect(res.keyPair.publicKey).toEqual(SAMPLE_DID.keyPair.publicKey)
expect(res.keyPair.algorithm).toEqual(KEY_ALG.ES256K)

const doc = await didResolver.resolve(res.did)
expect(doc).toBeDefined()
expect(doc.didResolutionMetadata.error).toBeFalsy()
})

it('Rejects generation from non-hex private key', async () => {
const pk = await randomBytes(64)
await expect(webDidMethod.generateFromPrivateKey(pk))
.rejects.toThrowError(DIDMethodFailureError)
})

it('Successfully resolve did:web identifier', async () => {
const res = await webDidMethod.resolve(SAMPLE_DID.id)
expect(res).toBeDefined()
expect(res.didDocument).toBeDefined()
expect(res.didDocument?.id).toEqual(SAMPLE_DID.id)
expect(res.didResolutionMetadata.error).toBeFalsy()
})

it('Resolution fails with did:key', async () => {
const didKey = 'did:key:z6Mkmo5LhWKseUg9SnDrfAirNqeL6LWX5DhFXF4RpQQprQNR'
expect(webDidMethod.resolve(didKey))
.rejects.toThrowError(DIDMethodFailureError)
})

it('Successfully extract did:web:mattr.global identifier', async () => {
expect(webDidMethod.getIdentifier('did:web:mattr.global#0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9'))
.toEqual('0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9')
expect(true).toEqual(true)
})

it('Successfully extract did:web identifier', async () => {
expect(webDidMethod.getIdentifier('did:web:0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9'))
.toEqual('0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9')
expect(true).toEqual(true)
})

it('Fails to extract incorrect did:web identifier', async () => {
expect(() => webDidMethod.getIdentifier('did:web:mattr.global#0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9'))
.toThrowError(DIDMethodFailureError)
})

it('Fails to extract incorrect did:web identifier', async () => {
expect(() => webDidMethod.getIdentifier('did:0x3b18dCa02FA6945aCBbE2732D8942781B410E0F9'))
.toThrowError(DIDMethodFailureError)
})

it('Successfully checks did:web isActive', async () => {
webDidMethod.resolve = jest.fn().mockResolvedValueOnce(DID_DOC)
const res = await webDidMethod.isActive(SAMPLE_DID.id)
expect(res).toBeTruthy()
})

it('Successfully checks active did:web isActive', async () => {
webDidMethod.resolve = jest.fn().mockResolvedValueOnce({...DID_DOC, didDocumentMetadata: { deactivated: false, versionId: '35961950', updated: '2023-05-23T20:59:17Z' }})
const res = await webDidMethod.isActive(SAMPLE_DID.id)
expect(res).toBeTruthy()
})

it('Successfully checks deactivated did:web isActive', async () => {
webDidMethod.resolve = jest.fn().mockResolvedValueOnce({...DID_DOC, didDocumentMetadata: { deactivated: true, versionId: '35961950', updated: '2023-05-23T20:59:17Z' }})
const res = await webDidMethod.isActive(SAMPLE_DID.id)
expect(res).toBeFalsy()
})
})