@veramo/credential-eip712
Version:
Veramo plugin for working with EIP712 Verifiable Credentials & Presentations.
409 lines (351 loc) • 13.1 kB
text/typescript
import {
CredentialPayload,
ICreateVerifiableCredentialArgs,
ICreateVerifiablePresentationArgs,
IIdentifier,
IKey,
IssuerAgentContext,
IVerifyCredentialArgs,
IVerifyPresentationArgs,
IVerifyResult,
PresentationPayload,
PROOF_FORMAT,
ProofFormat,
VerifiableCredential,
VerifiablePresentation,
VerifierAgentContext,
} from '@veramo/core-types'
import {
extractIssuer,
getChainId,
getEthereumAddress,
intersect,
isDefined,
MANDATORY_CREDENTIAL_CONTEXT,
mapIdentifierKeysToDoc,
processEntryToArray,
removeDIDParameters,
resolveDidOrThrow,
} from '@veramo/utils'
import { ICredentialProvider, ProofFormatQuery, TentativeVerificationQuery } from '@veramo/credential-w3c'
import { recoverTypedSignature, SignTypedDataVersion } from '@metamask/eth-sig-util'
import { getEthTypesFromInputDoc } from 'eip-712-types-generation'
/**
* A Veramo Credential sub-plugin that implements
* a {@link @veramo/credential-w3c#ICredentialProvider | ICredentialProvider} with support for
* EthereumEIP712Signature2021 proofs.
*
* @beta This API may change without a BREAKING CHANGE notice.
* @see {@link https://w3c-ccg.github.io/ethereum-eip712-signature-2021-spec/ | EthereumEIP712Signature2021 spec }
* @see {@link https://www.w3.org/TR/vc-data-model-1.1/ | VC 1.1 data model}.
*/
export class CredentialProviderEIP712 implements ICredentialProvider {
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.getProofFormatsSupportedForKey} */
getProofFormatsSupportedForKey(key: IKey): ProofFormat[] {
if (this.matchKeyForEIP712(key)) {
return [PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021]
}
return []
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.canIssueCredentialType} */
canIssueProofFormat(query: ProofFormatQuery): boolean {
return query.proofFormat === PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.canVerifyDocumentType} */
canVerifyDocumentType(query: TentativeVerificationQuery): boolean {
const { document } = query
return (<VerifiableCredential>document)?.proof?.type === PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.createVerifiableCredential} */
async createVerifiableCredential(
args: ICreateVerifiableCredentialArgs,
context: IssuerAgentContext,
): Promise<VerifiableCredential> {
const credentialContext = processEntryToArray(
args?.credential?.['@context'],
MANDATORY_CREDENTIAL_CONTEXT,
)
const credentialType = processEntryToArray(args?.credential?.type, 'VerifiableCredential')
let issuanceDate = args?.credential?.issuanceDate || new Date().toISOString()
if (issuanceDate instanceof Date) {
issuanceDate = issuanceDate.toISOString()
}
const issuer = extractIssuer(args.credential, { removeParameters: true })
if (!issuer || typeof issuer === 'undefined') {
throw new Error('invalid_argument: credential.issuer must not be empty')
}
let keyRef = args.keyRef
const identifier = await context.agent.didManagerGet({ did: issuer })
if (!keyRef) {
const key = identifier.keys.find(
(k) => k.type === 'Secp256k1' && k.meta?.algorithms?.includes('eth_signTypedData'),
)
if (!key) throw Error('key_not_found: No suitable signing key is known for ' + identifier.did)
keyRef = key.kid
}
const extendedKeys = await mapIdentifierKeysToDoc(
identifier,
'verificationMethod',
context,
args.resolutionOptions,
)
const extendedKey = extendedKeys.find((key) => key.kid === keyRef)
if (!extendedKey)
throw Error('key_not_found: The signing key is not available in the issuer DID document')
let chainId
try {
chainId = getChainId(extendedKey.meta.verificationMethod)
} catch (e) {
chainId = 1
}
const credential: CredentialPayload = {
...args?.credential,
'@context': credentialContext,
type: credentialType,
issuanceDate,
proof: {
verificationMethod: extendedKey.meta.verificationMethod.id,
created: issuanceDate,
proofPurpose: 'assertionMethod',
type: PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021,
},
}
const message = credential
const domain = {
chainId,
name: 'VerifiableCredential',
version: '1',
}
const primaryType = 'VerifiableCredential'
const allTypes = getEthTypesFromInputDoc(credential, primaryType)
const types = { ...allTypes }
const data = JSON.stringify({ domain, types, message, primaryType })
credential['proof']['proofValue'] = await context.agent.keyManagerSign({
keyRef,
data,
algorithm: 'eth_signTypedData',
})
credential['proof']['eip712'] = {
domain,
types: allTypes,
primaryType,
}
return credential as VerifiableCredential
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.verifyCredential} */
async verifyCredential(args: IVerifyCredentialArgs, context: VerifierAgentContext): Promise<IVerifyResult> {
const credential = args.credential as VerifiableCredential
if (!credential.proof || !credential.proof.proofValue)
throw new Error('invalid_argument: proof is undefined')
const { proof, ...signingInput } = credential
const { proofValue, eip712, eip712Domain, ...verifyInputProof } = proof
const verificationMessage = {
...signingInput,
proof: verifyInputProof,
}
const compat = {
...eip712Domain,
...eip712,
}
compat.types = compat.types || compat.messageSchema
if (!compat.primaryType || !compat.types || !compat.domain)
throw new Error('invalid_argument: proof is missing expected properties')
const objectToVerify = {
message: verificationMessage,
domain: compat.domain,
types: compat.types,
primaryType: compat.primaryType,
}
const recovered = recoverTypedSignature({
data: objectToVerify,
signature: proofValue!,
version: SignTypedDataVersion.V4,
})
const issuer = extractIssuer(credential)
if (!issuer || typeof issuer === 'undefined') {
throw new Error('invalid_argument: credential.issuer must not be empty')
}
const didDocument = await resolveDidOrThrow(issuer, context, args.resolutionOptions)
if (didDocument.verificationMethod) {
for (const verificationMethod of didDocument.verificationMethod) {
if (getEthereumAddress(verificationMethod)?.toLowerCase() === recovered.toLowerCase()) {
return {
verified: true,
}
}
}
} else {
throw new Error('resolver_error: issuer DIDDocument does not contain any verificationMethods')
}
return {
verified: false,
error: {
message: 'invalid_signature: The signature does not match any of the issuer signing keys',
errorCode: 'invalid_signature',
},
}
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.createVerifiablePresentation} */
async createVerifiablePresentation(
args: ICreateVerifiablePresentationArgs,
context: IssuerAgentContext,
): Promise<VerifiablePresentation> {
const presentationContext = processEntryToArray(
args?.presentation?.['@context'],
MANDATORY_CREDENTIAL_CONTEXT,
)
const presentationType = processEntryToArray(args?.presentation?.type, 'VerifiablePresentation')
let issuanceDate = args?.presentation?.issuanceDate || new Date().toISOString()
if (issuanceDate instanceof Date) {
issuanceDate = issuanceDate.toISOString()
}
const presentation: PresentationPayload = {
...args?.presentation,
'@context': presentationContext,
type: presentationType,
issuanceDate,
}
if (!isDefined(args.presentation.holder)) {
throw new Error('invalid_argument: presentation.holder must not be empty')
}
if (args.presentation.verifiableCredential) {
// EIP712 arrays must use a single data type, so we map all credentials to strings.
presentation.verifiableCredential = args.presentation.verifiableCredential.map((cred) => {
// map JWT credentials to their canonical form
if (typeof cred === 'string') {
return cred
} else if (cred.proof.jwt) {
return cred.proof.jwt
} else {
return JSON.stringify(cred)
}
})
}
const holder = removeDIDParameters(presentation.holder)
let identifier: IIdentifier
try {
identifier = await context.agent.didManagerGet({ did: holder })
} catch (e) {
throw new Error('invalid_argument: presentation.holder must be a DID managed by this agent')
}
let keyRef = args.keyRef
if (!keyRef) {
const key = identifier.keys.find(
(k) => k.type === 'Secp256k1' && k.meta?.algorithms?.includes('eth_signTypedData'),
)
if (!key) throw Error('key_not_found: No suitable signing key is known for ' + identifier.did)
keyRef = key.kid
}
const extendedKeys = await mapIdentifierKeysToDoc(
identifier,
'verificationMethod',
context,
args.resolutionOptions,
)
const extendedKey = extendedKeys.find((key) => key.kid === keyRef)
if (!extendedKey)
throw Error('key_not_found: The signing key is not available in the issuer DID document')
let chainId
try {
chainId = getChainId(extendedKey.meta.verificationMethod)
} catch (e) {
chainId = 1
}
presentation['proof'] = {
verificationMethod: extendedKey.meta.verificationMethod.id,
created: issuanceDate,
proofPurpose: 'assertionMethod',
type: PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021,
}
const message = presentation
const domain = {
chainId,
name: 'VerifiablePresentation',
version: '1',
}
const primaryType = 'VerifiablePresentation'
const allTypes = getEthTypesFromInputDoc(presentation, primaryType)
const types = { ...allTypes }
const data = JSON.stringify({ domain, types, message })
presentation.proof.proofValue = await context.agent.keyManagerSign({
keyRef,
data,
algorithm: 'eth_signTypedData',
})
presentation.proof.eip712 = {
domain,
types: allTypes,
primaryType,
}
return presentation as VerifiablePresentation
}
/** {@inheritdoc @veramo/credential-w3c#ICredentialProvider.verifyPresentation} */
async verifyPresentation(
args: IVerifyPresentationArgs,
context: VerifierAgentContext,
): Promise<IVerifyResult> {
const presentation = args.presentation as VerifiablePresentation
if (!presentation.proof || !presentation.proof.proofValue) throw new Error('Proof is undefined')
const { proof, ...signingInput } = presentation
const { proofValue, eip712, eip712Domain, ...verifyInputProof } = proof
const verificationMessage = {
...signingInput,
proof: verifyInputProof,
}
const compat = {
...eip712Domain,
...eip712,
}
compat.types = compat.types || compat.messageSchema
if (!compat.primaryType || !compat.types || !compat.domain)
throw new Error('invalid_argument: presentation proof is missing expected properties')
const objectToVerify = {
message: verificationMessage,
domain: compat.domain,
types: compat.types,
primaryType: compat.primaryType,
}
const recovered = recoverTypedSignature({
data: objectToVerify,
signature: proofValue!,
version: SignTypedDataVersion.V4,
})
const issuer = extractIssuer(presentation)
if (!issuer || typeof issuer === 'undefined') {
throw new Error('invalid_argument: args.presentation.issuer must not be empty')
}
const didDocument = await resolveDidOrThrow(issuer, context, args.resolutionOptions)
if (didDocument.verificationMethod) {
for (const verificationMethod of didDocument.verificationMethod) {
if (getEthereumAddress(verificationMethod)?.toLowerCase() === recovered.toLowerCase()) {
return {
verified: true,
}
}
}
} else {
throw new Error('resolver_error: holder DIDDocument does not contain any verificationMethods')
}
return {
verified: false,
error: {
message: 'invalid_signature: The signature does not match any of the holder signing keys',
errorCode: 'invalid_signature',
},
}
}
/**
* Checks if a key is suitable for signing EIP712 payloads.
* This relies on the metadata set by the key management system to determine if this key can sign EIP712 payloads.
*
* @param k - the key to check
*
* @internal
*/
matchKeyForEIP712(k: IKey): boolean {
return (
intersect(k.meta?.algorithms ?? [], ['eth_signTypedData', PROOF_FORMAT.ETHEREUM_EIP712_SIGNATURE_2021])
.length > 0
)
}
}