Skip to content

Commit

Permalink
refactor!: fetch attestation as part of verifyCredential (#708)
Browse files Browse the repository at this point in the history
* feat: credential attestation verification helper
* feat: report attestation <> credential mismatching attributes in error message
* refactor!: call verifyAttested from verifyCredential
* refactor!: return VerifiedCredential from verify{Credential,Presenation}
* fix: verifyAgainstCredential didn't check delegation
* chore: add {} to all ifs
* chore: improve docstrings
* test: add tests for attestation recheck
  • Loading branch information
rflechtner authored Mar 1, 2023
1 parent e10398f commit f8b54cd
Show file tree
Hide file tree
Showing 6 changed files with 324 additions and 60 deletions.
14 changes: 13 additions & 1 deletion packages/core/src/__integrationtests__/Attestation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe('When there is an attester, claimer and ctype drivers license', () => {
await expect(
Credential.verifySignature(presentation)
).resolves.not.toThrow()
await Credential.verifyPresentation(presentation)
Credential.verifyWellFormed(presentation, { ctype: driversLicenseCType })

const attestation = Attestation.fromCredentialAndDid(
presentation,
Expand All @@ -183,6 +183,10 @@ describe('When there is an attester, claimer and ctype drivers license', () => {
expect(storedAttestation).not.toBeNull()
expect(storedAttestation?.revoked).toBe(false)

await expect(
Credential.verifyPresentation(presentation)
).resolves.toMatchObject({ attester: attester.uri, revoked: false })

// Claim the deposit back by submitting the reclaimDeposit extrinsic with the deposit payer's account.
const reclaimTx = api.tx.attestation.reclaimDeposit(attestation.claimHash)
await submitTx(reclaimTx, tokenHolder)
Expand All @@ -191,6 +195,10 @@ describe('When there is an attester, claimer and ctype drivers license', () => {
expect(
(await api.query.attestation.attestations(attestation.claimHash)).isNone
).toBe(true)

await expect(
Credential.verifyPresentation(presentation)
).rejects.toThrowErrorMatchingInlineSnapshot(`"Attestation not found"`)
}, 60_000)

it('should not be possible to attest a claim without enough tokens', async () => {
Expand Down Expand Up @@ -399,6 +407,10 @@ describe('When there is an attester, claimer and ctype drivers license', () => {
)
expect(storedAttestationAfter).not.toBeNull()
expect(storedAttestationAfter?.revoked).toBe(true)

await expect(
Credential.verifyCredential(credential)
).resolves.toMatchObject({ attester: attester.uri, revoked: true })
}, 40_000)

it('should be possible for the deposit payer to remove an attestation', async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/__integrationtests__/Delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe('and attestation rights have been delegated', () => {
await expect(
Credential.verifySignature(presentation)
).resolves.not.toThrow()
await Credential.verifyPresentation(presentation)
Credential.verifyWellFormed(presentation, { ctype: driversLicenseCType })

const attestation = Attestation.fromCredentialAndDid(
credential,
Expand Down
20 changes: 15 additions & 5 deletions packages/core/src/attestation/Attestation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,22 @@ export function verifyAgainstCredential(
attestation: IAttestation,
credential: ICredential
): void {
if (
credential.claim.cTypeHash !== attestation.cTypeHash ||
credential.rootHash !== attestation.claimHash
) {
const credentialMismatch =
credential.claim.cTypeHash !== attestation.cTypeHash
const ctypeMismatch = credential.rootHash !== attestation.claimHash
const delegationMismatch =
credential.delegationId !== attestation.delegationId
if (credentialMismatch || ctypeMismatch || delegationMismatch) {
throw new SDKErrors.CredentialUnverifiableError(
'Attestation does not match credential'
`Some attributes of the on-chain attestation diverge from the credential: ${[
'cTypeHash',
'delegationId',
'claimHash',
]
.filter(
(_, i) => [ctypeMismatch, delegationMismatch, credentialMismatch][i]
)
.join(', ')}`
)
}
Credential.verifyDataIntegrity(credential)
Expand Down
205 changes: 182 additions & 23 deletions packages/core/src/credential/Credential.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@ import type {
import { Crypto, SDKErrors, UUID } from '@kiltprotocol/utils'
import * as Did from '@kiltprotocol/did'
import {
ApiMocks,
createLocalDemoFullDidFromKeypair,
KeyTool,
makeSigningKeyTool,
} from '@kiltprotocol/testing'
import { ConfigService } from '@kiltprotocol/config'
import { randomAsHex } from '@polkadot/util-crypto'
import * as Attestation from '../attestation'
import * as Claim from '../claim'
import * as CType from '../ctype'
import * as Credential from './Credential'
import { init } from '../kilt'

const testCType = CType.fromProperties('Credential', {
a: { type: 'string' },
Expand All @@ -62,6 +66,20 @@ function buildCredential(
return credential
}

beforeAll(async () => {
const api = ApiMocks.createAugmentedApi()
api.query.attestation = {
attestations: jest.fn().mockResolvedValue(
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: false,
attester: '4s5d7QHWSX9xx4DLafDtnTHK87n5e9G3UoKRrCDQ2gnrzYmZ',
ctypeHash: CType.idToHash(testCType.$id),
} as any)
),
} as any
await init({ api })
})

describe('Credential', () => {
const identityAlice =
'did:kilt:4nv4phaKc4EcwENdRERuMF79ZSSB5xvnAk3zNySSbVbXhSwS'
Expand All @@ -85,9 +103,11 @@ describe('Credential', () => {
)
// check proof on complete data
expect(() => Credential.verifyDataIntegrity(credential)).not.toThrow()
await Credential.verifyCredential(credential, {
ctype: testCType,
})
await expect(
Credential.verifyCredential(credential, {
ctype: testCType,
})
).resolves.toMatchObject({ revoked: false, attester: identityBob })

// just deleting a field will result in a wrong proof
delete credential.claimNonceMap[Object.keys(credential.claimNonceMap)[0]]
Expand Down Expand Up @@ -287,11 +307,19 @@ describe('Credential', () => {
[]
)
expect(() =>
Credential.verifyAgainstCType(builtCredential, testCType)
Credential.verifyWellFormed(builtCredential, { ctype: testCType })
).not.toThrow()
builtCredential.claim.contents.name = 123
const builtCredentialWrong = buildCredential(
identityBob,
{
a: 'a',
b: 'b',
c: 1,
},
[]
)
expect(() =>
Credential.verifyAgainstCType(builtCredential, testCType)
Credential.verifyWellFormed(builtCredentialWrong, { ctype: testCType })
).toThrow()
})

Expand All @@ -304,6 +332,103 @@ describe('Credential', () => {
Credential.fromClaim(claimA2).rootHash
)
})

it('re-checks attestation status', async () => {
const api = ConfigService.get('api')
const credential = buildCredential(
identityBob,
{
a: 'a',
b: 'b',
c: 'c',
},
[]
)

const { attester, revoked } = await Credential.verifyAttested(credential)
expect(revoked).toBe(false)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).resolves.toMatchObject({ revoked, attester })

jest.mocked(api.query.attestation.attestations).mockResolvedValueOnce(
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: true,
attester: Did.toChain(attester),
ctypeHash: credential.claim.cTypeHash,
} as any) as any
)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).resolves.toMatchObject({ revoked: true, attester })

await expect(
Credential.refreshRevocationStatus(credential as any)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"This function expects a VerifiedCredential with properties \`revoked\` (boolean) and \`attester\` (string)"`
)

jest
.mocked(api.query.attestation.attestations)
.mockResolvedValueOnce(
ApiMocks.mockChainQueryReturn('attestation', 'attestations') as any
)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).rejects.toThrowErrorMatchingInlineSnapshot(`"Attestation not found"`)

jest.mocked(api.query.attestation.attestations).mockResolvedValueOnce(
ApiMocks.mockChainQueryReturn(
'attestation',
'attestations',
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: false,
attester: Did.toChain(identityAlice),
ctypeHash: credential.claim.cTypeHash,
} as any) as any
) as any
)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Attester has changed since first verification"`
)

jest.mocked(api.query.attestation.attestations).mockResolvedValueOnce(
ApiMocks.mockChainQueryReturn(
'attestation',
'attestations',
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: true,
attester: Did.toChain(attester),
ctypeHash: randomAsHex(),
} as any) as any
) as any
)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Some attributes of the on-chain attestation diverge from the credential: claimHash"`
)

jest.mocked(api.query.attestation.attestations).mockResolvedValueOnce(
ApiMocks.mockChainQueryReturn(
'attestation',
'attestations',
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: true,
attester: Did.toChain(attester),
ctypeHash: credential.claim.cTypeHash,
authorizationId: { Delegation: randomAsHex() },
} as any) as any
) as any
)
await expect(
Credential.refreshRevocationStatus({ ...credential, revoked, attester })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Some attributes of the on-chain attestation diverge from the credential: delegationId"`
)
})
})

describe('Presentations', () => {
Expand Down Expand Up @@ -378,6 +503,16 @@ describe('Presentations', () => {
[],
keyAlice.getSignCallback(identityAlice)
)

jest
.mocked(ConfigService.get('api').query.attestation.attestations)
.mockResolvedValue(
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: false,
attester: Did.toChain(identityBob.uri),
ctypeHash: CType.idToHash(testCType.$id),
} as any) as any
)
})

it('verify credentials signed by a full DID', async () => {
Expand All @@ -395,9 +530,11 @@ describe('Presentations', () => {

// check proof on complete data
expect(() => Credential.verifyDataIntegrity(presentation)).not.toThrow()
await Credential.verifyPresentation(presentation, {
didResolveKey,
})
await expect(
Credential.verifyPresentation(presentation, {
didResolveKey,
})
).resolves.toMatchObject({ revoked: false, attester: identityBob.uri })
})
it('verify credentials signed by a light DID', async () => {
const { getSignCallback, authentication } = makeSigningKeyTool('ed25519')
Expand All @@ -419,9 +556,11 @@ describe('Presentations', () => {

// check proof on complete data
expect(() => Credential.verifyDataIntegrity(presentation)).not.toThrow()
await Credential.verifyPresentation(presentation, {
didResolveKey,
})
await expect(
Credential.verifyPresentation(presentation, {
didResolveKey,
})
).resolves.toMatchObject({ revoked: false, attester: identityBob.uri })
})

it('throws if signature is missing on credential presentation', async () => {
Expand Down Expand Up @@ -658,6 +797,16 @@ describe('create presentation', () => {
migratedClaimerFullDid.uri
)
)

jest
.mocked(ConfigService.get('api').query.attestation.attestations)
.mockResolvedValue(
ApiMocks.mockChainQueryReturn('attestation', 'attestations', {
revoked: false,
attester: Did.toChain(attester.uri),
ctypeHash: CType.idToHash(ctype.$id),
} as any) as any
)
})

it('should create presentation and exclude specific attributes using a full DID', async () => {
Expand All @@ -670,9 +819,11 @@ describe('create presentation', () => {
),
challenge,
})
await Credential.verifyPresentation(presentation, {
didResolveKey,
})
await expect(
Credential.verifyPresentation(presentation, {
didResolveKey,
})
).resolves.toMatchObject({ revoked: false, attester: attester.uri })
expect(presentation.claimerSignature?.challenge).toEqual(challenge)
})
it('should create presentation and exclude specific attributes using a light DID', async () => {
Expand All @@ -697,9 +848,11 @@ describe('create presentation', () => {
),
challenge,
})
await Credential.verifyPresentation(presentation, {
didResolveKey,
})
await expect(
Credential.verifyPresentation(presentation, {
didResolveKey,
})
).resolves.toMatchObject({ revoked: false, attester: attester.uri })
expect(presentation.claimerSignature?.challenge).toEqual(challenge)
})
it('should create presentation and exclude specific attributes using a migrated DID', async () => {
Expand All @@ -726,9 +879,11 @@ describe('create presentation', () => {
),
challenge,
})
await Credential.verifyPresentation(presentation, {
didResolveKey,
})
await expect(
Credential.verifyPresentation(presentation, {
didResolveKey,
})
).resolves.toMatchObject({ revoked: false, attester: attester.uri })
expect(presentation.claimerSignature?.challenge).toEqual(challenge)
})

Expand Down Expand Up @@ -795,9 +950,13 @@ describe('create presentation', () => {
})

it('should verify the credential claims structure against the ctype', () => {
expect(() => Credential.verifyAgainstCType(credential, ctype)).not.toThrow()
expect(() =>
CType.verifyClaimAgainstSchema(credential.claim.contents, ctype)
).not.toThrow()
credential.claim.contents.name = 123

expect(() => Credential.verifyAgainstCType(credential, ctype)).toThrow()
expect(() =>
CType.verifyClaimAgainstSchema(credential.claim.contents, ctype)
).toThrow()
})
})
Loading

0 comments on commit f8b54cd

Please sign in to comment.