diff --git a/packages/credentials/src/V1/KiltAttestationProofV1.ts b/packages/credentials/src/V1/KiltAttestationProofV1.ts index f74423ae2..739653a0f 100644 --- a/packages/credentials/src/V1/KiltAttestationProofV1.ts +++ b/packages/credentials/src/V1/KiltAttestationProofV1.ts @@ -30,30 +30,20 @@ import type { FrameSystemEventRecord, RuntimeCommonAuthorizationAuthorizationId, } from '@kiltprotocol/augment-api' -import { Blockchain } from '@kiltprotocol/chain-helpers' import { ConfigService } from '@kiltprotocol/config' -import { - authorizeTx, - fromChain, - getFullDid, - signersForDid, - validateDid, -} from '@kiltprotocol/did' +import { fromChain, getFullDid, validateDid } from '@kiltprotocol/did' import type { Did, ICType, IDelegationNode, - KiltAddress, SharedArguments, - SignerInterface, } from '@kiltprotocol/types' -import { Caip19, JsonSchema, SDKErrors, Signers } from '@kiltprotocol/utils' +import { Caip19, JsonSchema, SDKErrors } from '@kiltprotocol/utils' import { CTypeLoader } from '../ctype/CTypeLoader.js' import * as CType from '../ctype/index.js' import { IssuerOptions, - SimplifiedTransactionResult, // eslint-disable-next-line @typescript-eslint/no-unused-vars SubmitOverride, } from '../interfaces.js' @@ -67,6 +57,7 @@ import { assertMatchingConnection, credentialIdFromRootHash, credentialIdToRootHash, + defaultTxSubmit, delegationIdFromAttesterDelegation, ExpandedContents, getDelegationNodeIdForCredential, @@ -662,56 +653,6 @@ export function finalizeProof( } } -async function defaultTxSubmit({ - didDocument, - call, - signers, - submitter, -}: SharedArguments & { - call: Extrinsic -}): Promise { - let submitterAddress: KiltAddress - let accountSigners: SignerInterface[] = [] - if (typeof submitter === 'string') { - submitterAddress = submitter - accountSigners = ( - await Promise.all( - signers.map((keypair) => - 'algorithm' in keypair - ? [keypair] - : Signers.getSignersForKeypair({ keypair }) - ) - ) - ).flat() - } else if ('algorithm' in submitter) { - submitterAddress = submitter.id - accountSigners = [submitter] - } else { - accountSigners = await Signers.getSignersForKeypair({ - keypair: submitter, - }) - submitterAddress = accountSigners[0].id as KiltAddress - } - - let extrinsic = await authorizeTx( - didDocument, - call, - await signersForDid(didDocument, ...signers), - submitterAddress - ) - - if (!extrinsic.isSigned) { - extrinsic = await extrinsic.signAsync(submitterAddress, { - signer: Signers.getPolkadotSigner(accountSigners), - }) - } - const result = await Blockchain.submitSignedTx(extrinsic, { - resolveOn: Blockchain.IS_FINALIZED, - }) - const blockHash = result.status.asFinalized - return { block: { hash: blockHash.toHex() } } -} - /** * * Creates a complete {@link KiltAttestationProofV1} for issuing a new credential. diff --git a/packages/credentials/src/V1/KiltRevocationStatusV1.ts b/packages/credentials/src/V1/KiltRevocationStatusV1.ts index 16130a9d7..0219689e5 100644 --- a/packages/credentials/src/V1/KiltRevocationStatusV1.ts +++ b/packages/credentials/src/V1/KiltRevocationStatusV1.ts @@ -6,26 +6,97 @@ */ import { u8aEq, u8aToHex, u8aToU8a } from '@polkadot/util' -import { base58Decode, base58Encode } from '@polkadot/util-crypto' +import { base58Encode } from '@polkadot/util-crypto' import type { ApiPromise } from '@polkadot/api' import type { U8aLike } from '@polkadot/util/types' - import { ConfigService } from '@kiltprotocol/config' -import type { Caip2ChainId } from '@kiltprotocol/types' +import type { Caip2ChainId, SharedArguments } from '@kiltprotocol/types' import { Caip2, SDKErrors } from '@kiltprotocol/utils' - +import { Extrinsic } from '@polkadot/types/interfaces/' import * as CType from '../ctype/index.js' import * as Attestation from '../attestation/index.js' import { - assertMatchingConnection, + defaultTxSubmit, getDelegationNodeIdForCredential, + getRootHashFromStatusId, } from './common.js' +import type { IssuerOptions } from '../interfaces.js' import type { KiltCredentialV1, KiltRevocationStatusV1 } from './types.js' export type Interface = KiltRevocationStatusV1 export const STATUS_TYPE = 'KiltRevocationStatusV1' +/** + * Revokes a Verifiable Credential containing a KiltRevocationStatusV1. + * + * @param credentialStatus The `credentialStatus` property of the Verifiable Credential. + * @param issuer + * @param issuer.didDocument The DID Document of the issuer revoking the credential. + * @param issuer.signers Array of signer interfaces for credential authorization. + * @param issuer.submitter The submitter can be one of: + * - A MultibaseKeyPair for signing transactions. + * - A `KeyringPair` for blockchain interactions. + * The submitter will be used to cover transaction fees and blockchain operations. + * @param opts Additional parameters. + * @param opts.api An optional polkadot-js/api instance connected to the blockchain network on which the credential is anchored. + */ +export async function revoke( + credentialStatus: KiltRevocationStatusV1, + issuer: IssuerOptions, + opts: { api?: ApiPromise } = {} +): Promise { + const rootHash = getRootHashFromStatusId(credentialStatus, opts) + const { api = ConfigService.get('api') } = opts + const { didDocument, signers, submitter } = issuer + + // TODO: Support revocations through delegation. + // In this case, the second parameter in this function would needs to be populated. + const call = api.tx.attestation.revoke(rootHash, null) + + const args: Pick & { + call: Extrinsic + } = { + didDocument, + signers, + api, + call, + } + const transactionPromise = + typeof submitter === 'function' + ? submitter(args) + : defaultTxSubmit({ + ...args, + submitter, + }) + + const result = await transactionPromise + if ('status' in result) { + let error: Error | undefined + switch (result.status) { + case 'confirmed': + return + case 'failed': + error = result.asFailed.error + break + case 'rejected': + error = result.asRejected.error + break + case 'unknown': + error = result.asUnknown.error + break + default: + break + } + throw ( + error ?? + new SDKErrors.SDKError( + `Revocation failed with transaction status ${result?.status}` + ) + ) + } +} + /** * Check attestation and revocation status of a credential at the latest block available. * @@ -39,24 +110,8 @@ export async function check( opts: { api?: ApiPromise } = {} ): Promise { const { credentialStatus } = credential - if (credentialStatus?.type !== STATUS_TYPE) - throw new TypeError( - `The credential must have a credentialStatus of type ${STATUS_TYPE}` - ) + const rootHash = getRootHashFromStatusId(credentialStatus, opts) const { api = ConfigService.get('api') } = opts - const { assetNamespace, assetReference, assetInstance } = - assertMatchingConnection(api, credential) - if (assetNamespace !== 'kilt' || assetReference !== 'attestation') { - throw new Error( - `Cannot handle revocation status checks for asset type ${assetNamespace}:${assetReference}` - ) - } - if (!assetInstance) { - throw new SDKErrors.CredentialMalformedError( - "The attestation record's CAIP-19 identifier must contain an asset index ('token_id') decoding to the credential root hash" - ) - } - const rootHash = base58Decode(assetInstance) const encoded = await api.query.attestation.attestations(rootHash) if (encoded.isNone) throw new SDKErrors.CredentialUnverifiableError( diff --git a/packages/credentials/src/V1/common.ts b/packages/credentials/src/V1/common.ts index ade89432a..cfe30ec36 100644 --- a/packages/credentials/src/V1/common.ts +++ b/packages/credentials/src/V1/common.ts @@ -8,11 +8,26 @@ import type { ApiPromise } from '@polkadot/api' import { base58Decode, base58Encode } from '@polkadot/util-crypto' import { hexToU8a } from '@polkadot/util' +import { ConfigService } from '@kiltprotocol/config' -import type { HexString } from '@kiltprotocol/types' -import { Caip19, Caip2, SDKErrors } from '@kiltprotocol/utils' +import type { + HexString, + KiltAddress, + SharedArguments, + SignerInterface, +} from '@kiltprotocol/types' +import { Caip19, Caip2, SDKErrors, Signers } from '@kiltprotocol/utils' -import type { KiltAttesterDelegationV1, KiltCredentialV1 } from './types.js' +import { authorizeTx, signersForDid } from '@kiltprotocol/did' +import { Blockchain } from '@kiltprotocol/chain-helpers' +import { Extrinsic } from '@polkadot/types/interfaces' +import type { SimplifiedTransactionResult } from '../interfaces.js' +import type { + KiltAttesterDelegationV1, + KiltCredentialV1, + KiltRevocationStatusV1, +} from './types.js' +import { STATUS_TYPE } from './KiltRevocationStatusV1.js' export const spiritnetGenesisHash = hexToU8a( '0x411f057b9107718c9624d6aa4a3f23c1653898297f3d4d529d9bb6511a39dd21' @@ -154,3 +169,90 @@ export function credentialIdFromRootHash( const bytes = typeof rootHash === 'string' ? hexToU8a(rootHash) : rootHash return `${KILT_CREDENTIAL_IRI_PREFIX}${base58Encode(bytes, false)}` } + +/** + * @param root0 + * @param root0.didDocument DID Document of the authorizing DID. + * @param root0.call Extrinsic to be submitted. + * @param root0.signers An array of signer interfaces, each allowing to request signatures made with a key associated with the issuer DID Document. + * @param root0.submitter Submitter to cover the transaction. + * @private + */ +export async function defaultTxSubmit({ + didDocument, + call, + signers, + submitter, +}: SharedArguments & { + call: Extrinsic +}): Promise { + let submitterAddress: KiltAddress + let accountSigners: SignerInterface[] = [] + if (typeof submitter === 'string') { + submitterAddress = submitter + accountSigners = ( + await Promise.all( + signers.map((keypair) => + 'algorithm' in keypair + ? [keypair] + : Signers.getSignersForKeypair({ keypair }) + ) + ) + ).flat() + } else if ('algorithm' in submitter) { + submitterAddress = submitter.id + accountSigners = [submitter] + } else { + accountSigners = await Signers.getSignersForKeypair({ + keypair: submitter, + }) + submitterAddress = accountSigners[0].id as KiltAddress + } + + let extrinsic = await authorizeTx( + didDocument, + call, + await signersForDid(didDocument, ...signers), + submitterAddress + ) + + if (!extrinsic.isSigned) { + extrinsic = await extrinsic.signAsync(submitterAddress, { + signer: Signers.getPolkadotSigner(accountSigners), + }) + } + const result = await Blockchain.submitSignedTx(extrinsic, { + resolveOn: Blockchain.IS_FINALIZED, + }) + const blockHash = result.status.asFinalized + return { block: { hash: blockHash.toHex() } } +} + +/** + * @param credentialStatus Credential revocation status. + * @param opts + * @param opts.api Overrides the userd Kilt API. + */ +export function getRootHashFromStatusId( + credentialStatus: KiltRevocationStatusV1, + opts: { api?: ApiPromise } = {} +) { + if (credentialStatus?.type !== STATUS_TYPE) + throw new TypeError( + `The credential must have a credentialStatus of type ${STATUS_TYPE}` + ) + const { api = ConfigService.get('api') } = opts + const { assetNamespace, assetReference, assetInstance } = + assertMatchingConnection(api, { credentialStatus }) + if (assetNamespace !== 'kilt' || assetReference !== 'attestation') { + throw new Error( + `Cannot handle revocation status checks for asset type ${assetNamespace}:${assetReference}` + ) + } + if (!assetInstance) { + throw new SDKErrors.CredentialMalformedError( + "The attestation record's CAIP-19 identifier must contain an asset index ('token_id') decoding to the credential root hash" + ) + } + return base58Decode(assetInstance) +} diff --git a/packages/credentials/src/issuer.ts b/packages/credentials/src/issuer.ts index 712b40442..1d4907473 100644 --- a/packages/credentials/src/issuer.ts +++ b/packages/credentials/src/issuer.ts @@ -5,14 +5,16 @@ * found in the LICENSE file in the root directory of this source tree. */ -import type { Did, ICType, IClaimContents } from '@kiltprotocol/types' - import { SDKErrors } from '@kiltprotocol/utils' -import { KiltAttestationProofV1, KiltCredentialV1 } from './V1/index.js' -import type { UnsignedVc, VerifiableCredential } from './V1/types.js' +import type { Did, ICType, IClaimContents } from '@kiltprotocol/types' +import type { IssuerOptions } from './interfaces.js' import type { CTypeLoader } from './ctype/index.js' -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { IssuerOptions, SubmitOverride } from './interfaces.js' +import type { UnsignedVc, VerifiableCredential } from './V1/types.js' +import { + KiltAttestationProofV1, + KiltCredentialV1, + KiltRevocationStatusV1, +} from './V1/index.js' export type { IssuerOptions } @@ -134,3 +136,42 @@ export async function issue({ ) } } + +/** + * Revokes a Kilt credential on the blockchain, making it invalid. + * + * @param params Holds all named parameters. + * @param params.credential A credential document. + * @param params.issuer Interfaces for interacting with the issuer identity for the purpose of revoking the credential. + * @param params.issuer.didDocument The DID Document of the issuer. + * @param params.issuer.signers An array of signer interfaces, each allowing to request signatures made with a key associated with the issuer DID Document. + * The function will select the first signer that matches requirements around signature algorithm and relationship of the key to the DID as given by the DID Document. + * @param params.issuer.submitter Some proof types require making transactions to effect state changes on the KILT blockchain. + * The blockchain account whose address is specified here will be used to cover all transaction fees and deposits due for this operation. + * As transactions to the blockchain need to be signed, `signers` is expected to contain a signer interface where the `id` matches this address. + * + * Alternatively, you can pass a {@link SubmitOverride} callback that takes care of Did-authorizing and submitting the transaction. + * If you are using a service that helps you submit and pay for transactions, this is your point of integration to it. + * + * @throws If the credential format is invalid or the revocation fails. + */ +export async function revoke({ + credential, + issuer, +}: { + credential: VerifiableCredential + issuer: IssuerOptions +}): Promise { + const status = credential.credentialStatus + if (!status || status.type !== KiltRevocationStatusV1.STATUS_TYPE) { + throw new SDKErrors.SDKError( + `Only credential status type ${KiltRevocationStatusV1.STATUS_TYPE} is currently supported.` + ) + } + await KiltRevocationStatusV1.revoke( + status as KiltRevocationStatusV1.Interface, + issuer, + {} + ) + return credential +} diff --git a/tests/bundle/bundle-test.ts b/tests/bundle/bundle-test.ts index 43ca10776..20e7413a7 100644 --- a/tests/bundle/bundle-test.ts +++ b/tests/bundle/bundle-test.ts @@ -280,6 +280,27 @@ async function runAll() { console.log('presentation verified') + // ┏━━━━━━━━━━━━━━━━━━━━━━━┓ + // ┃ Revoke credential ┃ + // ┗━━━━━━━━━━━━━━━━━━━━━━━┛ + // Before revocation, credential status is valid. + let credentialStatus = await Kilt.Verifier.checkStatus({ credential }) + if (!credentialStatus.verified) { + throw new Error('credential already revoked') + } + // Revoke a previously issued credential on chain. + await Kilt.Issuer.revoke({ + issuer: { didDocument, signers, submitter }, + credential, + }) + console.log('credential revoked') + + // After revocation, credential status is invalid. + credentialStatus = await Kilt.Verifier.checkStatus({ credential }) + if (credentialStatus.verified) { + throw new Error('credential did not get revoked') + } + // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ Remove a Verification Method ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛