UNPKG

@proyecto-didi/didi-blockchain-manager

Version:
743 lines (668 loc) 21 kB
import { Resolver } from "did-resolver"; import Web3 from "web3"; const { Credentials } = require("uport-credentials"); const { createVerifiableCredentialJwt, verifyCredential, } = require("did-jwt-vc"); const DidRegistryContract = require("ethr-did-registry"); const didJWT = require("did-jwt"); const { delegateTypes, getResolver } = require("ethr-did-resolver"); const EthrDID = require("ethr-did"); export interface BlockchainManagerConfig { gasPrice: number; providerConfig: any; } export interface Options { from: string; gasPrice?: number; gas?: number; nonce?: number; } export interface Identity { did: string; privateKey: string; } export interface NetworkConfig { provider: string; address: string; name: string; } export interface OperationResponse { status: "fulfilled" | "rejected"; network: string; value: any; } export interface CredentialVerificationResponse { isIssuerValid: boolean; payload: any; doc: any; issuer: string; signer: any; jwt: string; } const blockChainSelector = ( networkConfig: { name: string; rpcUrl: string; registry: string }[], did: string ): NetworkConfig => { let routerCharPos = -1; let index = -1; let i = 1; let searchArray = true; const noUportPrefixDid = did.slice(9, did.length); routerCharPos = noUportPrefixDid.search(":"); // if routerCharPos > 0 there's another prefix, should search the provider array if (routerCharPos === -1) { // if not, connect directly to mainnet searchArray = false; index = 0; } while (i < networkConfig.length && searchArray) { routerCharPos = did.search(networkConfig[i].name); if (routerCharPos > 0) { index = i; // saves index for connection later searchArray = false; } else { i += 1; // provider not found, keep going through array } } const blockchainToConnect: NetworkConfig = { provider: null, address: null, name: null, }; if (index >= 0) { blockchainToConnect.provider = networkConfig[index].rpcUrl; blockchainToConnect.address = networkConfig[index].registry; blockchainToConnect.name = networkConfig[index].name; return blockchainToConnect; } throw new Error("Invalid Provider Prefix"); }; export function addPrefix(prefixToAdd, did) { const prefixedDid = did.slice(0, 9) + prefixToAdd + did.slice(9, did.length); return prefixedDid; } const checkPrefix = (prefix, networkArray) => { let i = 0; let notFounded = true; while (i < networkArray.length && notFounded) { if (prefix === networkArray[i].name) { notFounded = false; } else { i += 1; } } return !notFounded; }; export class BlockchainManager { config: BlockchainManagerConfig; didResolver: Resolver; gasSafetyValue?: number; gasPriceSafetyValue?: number; constructor( config: BlockchainManagerConfig, gasSafetyValue: number = 1.2, gasPriceSafetyValue: number = 1.1 ) { this.config = config; this.didResolver = new Resolver(getResolver(config.providerConfig)); this.gasSafetyValue = gasSafetyValue; this.gasPriceSafetyValue = gasPriceSafetyValue; } static delegateType = delegateTypes.Secp256k1SignatureAuthentication2018; /** * Get the minimum gas price for the given method and options * @returns {number} */ async getGasPrice(web3) { const gasPrice = await web3.eth.getGasPrice(); const retGasPrice = Math.round( parseInt(gasPrice, 10) * this.gasPriceSafetyValue ); return retGasPrice; } /** * Get gas limit given the method and options * @returns {number} */ async getGasLimit(method, options) { // 21000 is a recommended number const gasQty = Math.round( Math.max(await method.estimateGas(options), 21000) * this.gasSafetyValue ); return gasQty; } /** * Obtains the ethr-did-registry contract * @param options * @returns {Contract} */ static getDidContract(options, contractAddress, web3) { return new web3.eth.Contract(DidRegistryContract.abi, contractAddress, { from: options.from, }); } /** * Returns the address of the DID * @param {string} did Did to get the address from * @returns {string} */ static getDidAddress(did: string) { const cleanDid = did.split(":"); return cleanDid[cleanDid.length - 1]; } /** * * @param {string} did Did to get the blockchain name from */ static getDidBlockchain(did: string) { const didAsArray = did.split(":"); return didAsArray.length === 4 ? didAsArray[2] : null; } /** * Add a blockchain beofre the ethereum address. Throws if already contains * a blockchain. did:ethr:0x123 => did:ethr:rinkeby:0x123 * @param {string} did Did to add the blockchain * @param {string} blockchain Blockchain to add */ static addBlockchainToDid(did: string, blockchain: string) { const didAsArray = did.split(":"); if (didAsArray.length === 4) throw new Error("#blockchainManager-didWithNetwork"); didAsArray.splice(2, 0, blockchain); return didAsArray.join(":"); } /** * Remove netowrk from did. If the did doesn't contain a network, it returns same did * did:ethr:net:0x123 => did:ethr:0x123 * did:ethr:0x123 => did:ethr:0x123 * * @param {string} did DID to remove the network */ static removeBlockchainFromDid(did: string): string { const didAsArray = did.split(":"); if (didAsArray.length === 3) return did; didAsArray.splice(2, 1); return didAsArray.join(":"); } /** * Compare two dids DIDs. A DIDI without netowors is equal to a DID with network and * same address. Two DIDs with same netork and different address are different. Ex: * did:ethr:net:0x123 == did:ethr:0x123 * did:ethr:net1:0x123 != did:ethr:net2:0x123 * did:ethr:net:0x123 != did:ethr:net:0x124 * did:ethr:0x123 != did:ethr:0x124 * did:ethr:net1:0x123 != did:ethr:net2:0x124 * @param {string} did1 * @param {string} did2 */ static compareDid(did1: string, did2: string) { const didAddress1 = BlockchainManager.getDidAddress(did1); const didAddress2 = BlockchainManager.getDidAddress(did2); const didBlockchain1 = BlockchainManager.getDidBlockchain(did1); const didBlockchain2 = BlockchainManager.getDidBlockchain(did2); if (didBlockchain1 == null || didBlockchain2 == null) { return didAddress1 === didAddress2; } return didAddress1 === didAddress2 && didBlockchain1 === didBlockchain2; } /** * If syncing throws #blockchainManager-nodeIsSyncing * @param web3 web3 instance */ static async onlySynced({ eth }: Web3): Promise<void> { try { const isSyncingResponse = await eth.isSyncing(); if (isSyncingResponse) throw new Error("#blockchainManager-nodeIsSyncing"); } catch (e) { // RSK public node don't allow eth_syncing. We assume that is always in sync if (e.message.includes("403 Method Not Allowed")) { return; } throw e; } } /** * Given a network add delegateDID as a delegate of identity * @param {NetworkConfig} blockchainToConnect * @param {Identity} identity * @param {string} delegateDID * @param {string} validity */ private async delegateOnBlockchain( blockchainToConnect: NetworkConfig, identity: Identity, delegateDID: string, validity: string ) { const provider = new Web3.providers.HttpProvider( blockchainToConnect.provider ); const web3 = new Web3(provider); await BlockchainManager.onlySynced(web3); const identityAddr = BlockchainManager.getDidAddress(identity.did); const delegateAddr = BlockchainManager.getDidAddress(delegateDID); const options: Options = { from: identityAddr, }; const contract = BlockchainManager.getDidContract( options, blockchainToConnect.address, web3 ); const account = web3.eth.accounts.privateKeyToAccount(identity.privateKey); web3.eth.accounts.wallet.add(account); const addDelegateMethod = contract.methods.addDelegate( identityAddr, BlockchainManager.delegateType, delegateAddr, validity ); options.gas = await this.getGasLimit(addDelegateMethod, options); options.gasPrice = await this.getGasPrice(web3); options.nonce = blockchainToConnect.name !== "lacchain" ? await web3.eth.getTransactionCount(identityAddr, "pending") : undefined; let delegateMethodSent; try { delegateMethodSent = await addDelegateMethod.send(options); } catch (e) { if (BlockchainManager.isUnknownError(e)) { throw e; } delegateMethodSent = await this.delegateOnBlockchain( blockchainToConnect, identity, delegateDID, validity ); } web3.eth.accounts.wallet.remove(account.address); return delegateMethodSent; } /** * Add delegateDID as a delegate of identity on one or more networks * @param {Identity} identity * @param {string} delegateDID * @param {string} validity */ async addDelegate( identity: Identity, delegateDID: string, validity: string ): Promise<OperationResponse[]> { const blockchain = BlockchainManager.getDidBlockchain(delegateDID); if (blockchain) { const blockchainToConnect: NetworkConfig = blockChainSelector( this.config.providerConfig.networks, delegateDID ); const [delegation]: any = await Promise.allSettled([ this.delegateOnBlockchain( blockchainToConnect, identity, delegateDID, validity ), ]); return [ { network: blockchainToConnect.name, status: delegation.status, value: delegation.reason || delegation.value, }, ]; } const validNetworks = this.config.providerConfig.networks.filter( ({ name }) => !!name ); const delegations = validNetworks .map(({ rpcUrl, registry, name }) => ({ provider: rpcUrl, address: registry, name, })) .map((network: NetworkConfig) => this.delegateOnBlockchain(network, identity, delegateDID, validity) ); // PromiseConstructor.allSettled<any>(values: any) should be an array ,but is a single value const settledDelegations: any = await Promise.allSettled(delegations); return settledDelegations.map((result, index) => ({ network: validNetworks[index].name, ...result, })); } /** * Given a blockchain and an issuer, validate the delegate * @param {NetworkConfig} blockchainToConnect * @param {String} identityAddr * @param {String} delegateAddr */ private static async validateOnBlockchain( blockchainToConnect: NetworkConfig, identityAddr: string, delegateAddr: string ): Promise<boolean> { const provider = new Web3.providers.HttpProvider( blockchainToConnect.provider ); const web3 = new Web3(provider); await BlockchainManager.onlySynced(web3); const options: Options = { from: identityAddr, }; const contract = BlockchainManager.getDidContract( options, blockchainToConnect.address, web3 ); const validDelegateMethod = contract.methods.validDelegate( identityAddr, BlockchainManager.delegateType, delegateAddr ); return validDelegateMethod.call(options); } /** * validate if delegateDID is delegate of identityDID * @param {Identity} identityDID * @param {string} delegateDID */ async validDelegate( identityDID: string, delegateDID: string ): Promise<boolean> { const identityAddr = BlockchainManager.getDidAddress(identityDID); const delegateAddr = BlockchainManager.getDidAddress(delegateDID); const blockchain = BlockchainManager.getDidBlockchain(delegateDID); if (blockchain) { const blockchainToConnect: NetworkConfig = blockChainSelector( this.config.providerConfig.networks, delegateDID ); return BlockchainManager.validateOnBlockchain( blockchainToConnect, identityAddr, delegateAddr ); } const validations = this.config.providerConfig.networks.map((network) => { const blockchainToConnect: NetworkConfig = { provider: network.rpcUrl, address: network.registry, name: network.name, }; return BlockchainManager.validateOnBlockchain( blockchainToConnect, identityAddr, delegateAddr ); }); const results: any = await Promise.allSettled(validations); return results.some((result) => result.value === true); } /** * Resolve a DID document * @param {string} did DID to resolve its document */ async resolveDidDocument(did: string) { const resolvedDid = await this.didResolver.resolve(did); return resolvedDid; } /** * Creates a JWT from a base payload with the information to encode * @param {string} issuerDid Issuer DID * @param {object} payload Information of the JWT * @param {string} pkey Information of the PK * @param {number} expiration Expiration of the JWT in [NumericDate]{@link https://tools.ietf.org/html/rfc7519#section-2} * @param {string} audienceDID DID of the audience of the JWT * @returns {string} JWT's string */ static async createJWT( issuerDid: string, pkey: string, payload: any, expiration: number = undefined, audienceDID: string = undefined ) { const signer = didJWT.SimpleSigner(pkey); const response = await didJWT.createJWT( { ...payload, exp: expiration, aud: audienceDID, }, { issuer: issuerDid, signer }, { alg: "ES256K-R" } ); return response; } /** * Creates a valid signer * @param {string} privateKey A hex encoded private key * @returns {Signer} A configured signer function */ static getSigner(privateKey: string) { return didJWT.SimpleSigner(privateKey); } /** * Verify a JWT string with the given audience * @param {string} jwt JWT to be verified * @param {string} audienceDID DID of the audience if needed */ async verifyJWT(jwt, audienceDID = undefined) { const response = await didJWT.verifyJWT(jwt, { resolver: this.didResolver, audience: audienceDID, }); response.doc = response.didResolutionResult.didDocument; return response; } /** * Waring: Use verifyJWT. Decodes a token and returns the contet. * @param {string} jwt */ static async decodeJWT(jwt) { return didJWT.decodeJWT(jwt); } /** * genera un certificado asociando la informacion recibida en "subject" con el did * @param {string} subjectDid This did has this prefix always (did:ethr:) it doesn't change * @param {string} subjectPayload * @param {Date} expirationDate * @param {string} issuerDid The issuer might change and has different prefixes * @param {string} issuerPkey */ static async createCredential( subjectDid, subjectPayload, expirationDate, issuerDid, issuerPkey ): Promise<string> { const cleanDid = issuerDid.split(":"); const prefixedDid = cleanDid.slice(2).join(":"); const vcIssuer = new EthrDID({ address: prefixedDid, privateKey: issuerPkey, }); const date = expirationDate ? Math.floor(new Date(expirationDate).getTime() / 1000 || 0) : undefined; const vcPayload = { sub: subjectDid, vc: { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], credentialSubject: subjectPayload, }, exp: date, }; const result = await createVerifiableCredentialJwt(vcPayload, vcIssuer); return result; } /** * Verifies a credential using the universal resolver and verifies issuer * @param {string} jwt Credential encoded as jwt * @param {string} IdentityDid Central entity DID, usually DIDI */ async verifyCredential( jwt: string, IdentityDid?: string ): Promise<CredentialVerificationResponse> { const credentialVerification = verifyCredential(jwt, this.didResolver); if (!IdentityDid) return credentialVerification; const isIssuerValid = this.validDelegate( IdentityDid, credentialVerification.issuer ); return { isIssuerValid, ...credentialVerification, }; } /** * Given an prefix, genereates new privte and public keys. * @param {string} prefixToAdd */ createIdentity(prefixToAdd: string = "") { let prefixChecked = false; let prefixedDid = null; if (prefixToAdd) { prefixChecked = checkPrefix( prefixToAdd, this.config.providerConfig.networks ); if (!prefixChecked) { throw new Error( "Invalid Prefix - Check Provider Network Configuration" ); } } const credential = Credentials.createIdentity(); if (prefixToAdd) { prefixedDid = addPrefix(`${prefixToAdd}:`, credential.did); credential.did = prefixedDid; } return credential; } /** * Given a blockchain revoke a delegate * @param {NetworkConfig} blockchainToConnect * @param {string} delegatedDID * @param {Identity} issuerCredentials */ private async revokeOnBlockchain( blockchainToConnect: NetworkConfig, delegatedDID: string, issuerCredentials: Identity ) { const sourceAddress = BlockchainManager.getDidAddress( issuerCredentials.did ); const targetAddress = BlockchainManager.getDidAddress(delegatedDID); const provider = new Web3.providers.HttpProvider( blockchainToConnect.provider ); const web3 = new Web3(provider); const options: Options = { from: sourceAddress }; const contract = BlockchainManager.getDidContract( options, blockchainToConnect.address, web3 ); const account = web3.eth.accounts.privateKeyToAccount( issuerCredentials.privateKey ); web3.eth.accounts.wallet.add(account); const revokeDelegateMethod = contract.methods.revokeDelegate( sourceAddress, BlockchainManager.delegateType, targetAddress ); options.gas = await this.getGasLimit(revokeDelegateMethod, options); options.gasPrice = await this.getGasPrice(web3); options.nonce = blockchainToConnect.name !== "lacchain" ? await web3.eth.getTransactionCount(sourceAddress, "pending") : undefined; let revokeMethodSent; try { revokeMethodSent = await revokeDelegateMethod.send(options); } catch (e) { if (BlockchainManager.isUnknownError(e)) { throw e; } revokeMethodSent = await this.revokeDelegate( issuerCredentials, delegatedDID ); } web3.eth.accounts.wallet.remove(account.address); return revokeMethodSent; } /** * Revoke delegation * @param {Identity} issuerCredentials * @param {string} delegatedDID */ async revokeDelegate( issuerCredentials, delegatedDID ): Promise<OperationResponse[]> { const blockchain = BlockchainManager.getDidBlockchain(delegatedDID); if (blockchain) { const blockchainToConnect: NetworkConfig = blockChainSelector( this.config.providerConfig.networks, delegatedDID ); const revoke: any = await Promise.allSettled([ this.revokeOnBlockchain( blockchainToConnect, delegatedDID, issuerCredentials ), ]); return [ { network: blockchainToConnect.name, status: revoke[0].status, value: revoke[0].reason || revoke[0].value, }, ]; } const validNetworks = this.config.providerConfig.networks.filter( ({ name }) => !!name ); const delegations = validNetworks .map(({ rpcUrl, registry, name }) => ({ provider: rpcUrl, address: registry, name, })) .map((network: NetworkConfig) => this.revokeOnBlockchain(network, delegatedDID, issuerCredentials) ); // PromiseConstructor.allSettled<any>(values: any) should be an array ,but is a single value const settledDelegations: any = await Promise.allSettled(delegations); return settledDelegations.map((result, index) => ({ network: validNetworks[index].name, ...result, })); } /** * We dont want to bump txs. This only happen if simultaneous tx are sent, this resend recursively * the tx increasing nonce by one * @param error */ static isUnknownError(error) { return !( error.message.includes("gas price not enough to bump transaction") || error.message.includes("transaction underpriced") || error.message.includes("too low") || error.message.includes("too high") ); } }