diff --git a/jest.config.js b/jest.config.js index 750171f1b..c943e7ec1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -37,9 +37,9 @@ const common = { 'index.ts', 'types.ts', '.chain.ts', + 'DelegationDecoder.ts', 'SDKErrors.ts', 'Did.rpc.ts', - 'packages/utils/src/Chain.ts', // third party code copied to this repo 'packages/utils/src/json-schema/', 'jsonabc.ts', diff --git a/package.json b/package.json index f9eda9b39..534f95b70 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,13 @@ "license": "BSD-4-Clause", "scripts": { "check": "tsc -p tsconfig.json --noEmit", - "build": "yarn workspaces foreach -p -t --exclude '{root-workspace}' run build", + "build": "yarn workspaces foreach -ptAv --exclude '{root-workspace}' run build", "build:docs": "typedoc --theme default --out docs/api --tsconfig tsconfig.docs.json && touch docs/.nojekyll", "bundle": "yarn workspace @kiltprotocol/sdk-js run bundle", "clean": "rimraf tests/bundle/dist && rimraf tests/integration/dist && yarn workspaces foreach -p --exclude '{root-workspace}' run clean", "clean:docs": "rimraf docs/api", - "prepublish": "yarn workspaces foreach -p --no-private exec cp -f ../../LICENSE .", - "publish": "yarn workspaces foreach -pt --no-private npm publish", + "prepublish": "yarn workspaces foreach -pA --no-private exec cp -f ../../LICENSE .", + "publish": "yarn workspaces foreach -ptAv --no-private npm publish", "lint": "eslint packages tests --format=codeframe", "lint:fix": "yarn lint --fix", "set:version": "npm version --no-git-tag-version --no-workspaces-update --workspaces --include-workspace-root", diff --git a/packages/legacy-credentials/README.md b/packages/legacy-credentials/README.md new file mode 100644 index 000000000..ec78d1901 --- /dev/null +++ b/packages/legacy-credentials/README.md @@ -0,0 +1,5 @@ +[![](https://user-images.githubusercontent.com/39338561/122415864-8d6a7c00-cf88-11eb-846f-a98a936f88da.png)](https://kilt.io) + +![Lint and Test](https://github.com/KILTprotocol/sdk-js/workflows/Lint%20and%20Test/badge.svg) + +# KILT Legacy Credentials Support diff --git a/packages/legacy-credentials/package.json b/packages/legacy-credentials/package.json new file mode 100644 index 000000000..76f08980a --- /dev/null +++ b/packages/legacy-credentials/package.json @@ -0,0 +1,46 @@ +{ + "name": "@kiltprotocol/legacy-credentials", + "version": "0.33.2-6", + "description": "", + "main": "./lib/cjs/index.js", + "module": "./lib/esm/index.js", + "types": "./lib/cjs/index.d.ts", + "exports": { + ".": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + } + }, + "files": [ + "lib/**/*" + ], + "scripts": { + "clean": "rimraf ./lib", + "build": "yarn clean && yarn build:ts", + "build:ts": "yarn build:cjs && yarn build:esm", + "build:cjs": "tsc --declaration -p tsconfig.build.json && echo '{\"type\":\"commonjs\"}' > ./lib/cjs/package.json", + "build:esm": "tsc --declaration -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > ./lib/esm/package.json" + }, + "repository": "github:kiltprotocol/sdk-js", + "engines": { + "node": ">=16.0" + }, + "author": "", + "license": "BSD-4-Clause", + "bugs": "https://github.com/KILTprotocol/sdk-js/issues", + "homepage": "https://github.com/KILTprotocol/sdk-js#readme", + "devDependencies": { + "rimraf": "^3.0.2", + "typescript": "^4.8.3" + }, + "dependencies": { + "@kiltprotocol/config": "workspace:*", + "@kiltprotocol/core": "workspace:*", + "@kiltprotocol/did": "workspace:*", + "@kiltprotocol/types": "workspace:*", + "@kiltprotocol/utils": "workspace:*", + "@kiltprotocol/vc-export": "workspace:*", + "@polkadot/util": "^12.0.0", + "@polkadot/util-crypto": "^12.0.0" + } +} diff --git a/packages/legacy-credentials/src/Claim.spec.ts b/packages/legacy-credentials/src/Claim.spec.ts new file mode 100644 index 000000000..ba8b7f991 --- /dev/null +++ b/packages/legacy-credentials/src/Claim.spec.ts @@ -0,0 +1,184 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +import { CType } from '@kiltprotocol/core' +import type { DidUri, ICType, IClaim } from '@kiltprotocol/types' +import { SDKErrors } from '@kiltprotocol/utils' + +import * as Claim from './Claim' + +describe('jsonld', () => { + const claim: IClaim = { + cTypeHash: + '0x90364302f3b6ccfa50f3d384ec0ab6369711e13298ba4a5316d7e2addd5647b2', + contents: { + name: 'John', + address: 'homestreet, home', + number: 26, + optIn: true, + }, + owner: 'did:kilt:4r1WkS3t8rbCb11H8t3tJvGVCynwDXSUBiuGB6sLRHzCLCjs', + } + + it('validates hashes from snapshot', () => { + // given some nonces... + const nonces = [ + '276b53b6-37db-4179-822e-ed2337a5b889', + 'a63ff753-2622-4312-b612-571495c1bc9d', + 'd5ceaebd-1e0e-432a-a501-58baa2f66e59', + '8db12d9e-26c0-49c0-bf91-818d6cc6116a', + '81687a00-f759-4fee-a68c-d9085f9d32f5', + ] + const digests = Object.keys(Claim.hashClaimContents(claim).nonceMap) + const nonceMap = digests + .sort() + .reduce( + (previous, current, i) => ({ ...previous, [current]: nonces[i] }), + {} + ) + // we expect the resulting hashes to be the same every time + const hashed = Claim.hashClaimContents(claim, { + nonces: nonceMap, + }) + expect(hashed.nonceMap).toEqual(nonceMap) + expect(hashed.hashes).toMatchInlineSnapshot(` + [ + "0x3c2ae125a0baf4ed64a30b7ad012810b4622628a2eb5ad32e769e6a1d356d58d", + "0x69aae66efd954c3712e91dd2761dab08ea941e6516e7cf6ddf6e3b90ddc5bdf3", + "0x8d5736197583931c4e4d3dce0503596760f7a13e8187cc440b7de1edd4370d6a", + "0xad82658110207f8e65e1c2ae196ec7952aacda0aa9f19c83ce18b60612fe909a", + "0xf5db5c377a5e85ba94a457d5be8b8ec05419c3e0a666147d6ed86b45089374bd", + ] + `) + }) +}) + +describe('compute hashes & validate by reproducing them', () => { + const claim: IClaim = { + cTypeHash: + '0x90364302f3b6ccfa50f3d384ec0ab6369711e13298ba4a5316d7e2addd5647b2', + contents: { + name: 'John', + address: 'homestreet, home', + number: 26, + optIn: true, + }, + owner: 'did:kilt:4r1WkS3t8rbCb11H8t3tJvGVCynwDXSUBiuGB6sLRHzCLCjs', + } + + // hash claim contents with randomly generated nonces + const hashed = Claim.hashClaimContents(claim) + const { hashes, nonceMap: nonces } = hashed + + it('reproduces hashes of full claim', () => { + // when rehashing with the same nonces, the result must be identical + const rehashed = Claim.hashClaimContents(claim, { nonces }) + expect(hashes).toEqual(rehashed.hashes) + }) + + it('reproduces hashes of partial claims', () => { + // when rehashing a partial claim (some properties removed) while using the original nonces, + // the resulting hashes must be among those computed in the original hashing + Object.keys(claim.contents).forEach((property) => { + // deep copy, then delete only a single property + const partialClaim = JSON.parse(JSON.stringify(claim)) + delete partialClaim.contents[property] + Claim.hashClaimContents(partialClaim, { nonces }).hashes.forEach( + (hash) => { + expect(hashes).toContain(hash) + } + ) + // remove all but one single property + partialClaim.contents = { [property]: claim.contents[property] } + Claim.hashClaimContents(partialClaim, { nonces }).hashes.forEach( + (hash) => { + expect(hashes).toContain(hash) + } + ) + }) + }) +}) + +describe('Claim', () => { + let did: DidUri + let claimContents: any + let testCType: ICType + let claim: IClaim + + beforeAll(async () => { + did = 'did:kilt:4r1WkS3t8rbCb11H8t3tJvGVCynwDXSUBiuGB6sLRHzCLCjs' + + claimContents = { + name: 'Bob', + } + + testCType = CType.fromProperties('ClaimCtype', { + name: { type: 'string' }, + }) + + claim = Claim.fromCTypeAndClaimContents(testCType, claimContents, did) + }) + + it('can be made from object', () => { + const claimObj = JSON.parse(JSON.stringify(claim)) + expect(() => Claim.verify(claimObj, testCType)).not.toThrow() + }) + + it('allows falsy claim values', () => { + const claimWithFalsy: IClaim = { + ...claim, + contents: { + name: '', + }, + } + expect(() => Claim.verifyDataStructure(claimWithFalsy)).not.toThrow() + }) + + it('should throw an error on faulty constructor input', () => { + const cTypeHash = CType.idToHash(testCType.$id) + const ownerAddress = did + + const everything = { + cTypeHash, + contents: claimContents, + owner: ownerAddress, + } as IClaim + + // @ts-ignore + const noCTypeHash = { + cTypeHash: '', + contents: claimContents, + owner: ownerAddress, + } as IClaim + + const malformedCTypeHash = { + cTypeHash: cTypeHash.slice(0, 20) + cTypeHash.slice(21), + contents: claimContents, + owner: ownerAddress, + } as IClaim + + const malformedAddress = { + cTypeHash, + contents: claimContents, + owner: ownerAddress.replace('8', 'D'), + } as IClaim + + expect(() => Claim.verifyDataStructure(everything)).not.toThrow() + + expect(() => Claim.verifyDataStructure(noCTypeHash)).toThrowError( + SDKErrors.CTypeHashMissingError + ) + + expect(() => Claim.verifyDataStructure(malformedCTypeHash)).toThrowError( + SDKErrors.HashMalformedError + ) + + expect(() => Claim.verifyDataStructure(malformedAddress)).toThrowError( + SDKErrors.AddressInvalidError + ) + }) +}) diff --git a/packages/legacy-credentials/src/Claim.ts b/packages/legacy-credentials/src/Claim.ts new file mode 100644 index 000000000..cf21a4b6f --- /dev/null +++ b/packages/legacy-credentials/src/Claim.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +/** + * Claims are a core building block of the KILT SDK. A claim represents **something an entity claims about itself**. Once created, a claim can be used to create a [[Credential]]. + * + * A claim object has: + * * contents - among others, the pure content of a claim, for example `"isOver18": true`; + * * a [[CType]] that represents its data structure. + * + * A claim object's owner is (should be) the same entity as the claimer. + * + * @packageDocumentation + */ + +import { CType } from '@kiltprotocol/core' +import * as Did from '@kiltprotocol/did' +import type { + DidUri, + HexString, + ICType, + IClaim, + PartialClaim, +} from '@kiltprotocol/types' +import { Crypto, DataUtils, SDKErrors } from '@kiltprotocol/utils' +import { hexToBn } from '@polkadot/util' + +import { makeStatementsJsonLD } from './utils.js' + +/** + * Produces salted hashes of individual statements comprising a (partial) [[IClaim]] to enable selective disclosure of contents. Can also be used to reproduce hashes for the purpose of validation. + * + * @param claim Full or partial [[IClaim]] to produce statement hashes from. + * @param options Object containing optional parameters. + * @param options.canonicalisation Canonicalisation routine that produces an array of statement strings from the [IClaim]. Default produces individual `{"key":"value"}` JSON representations where keys are transformed to expanded JSON-LD. + * @param options.nonces Optional map of nonces as produced by this function. + * @param options.nonceGenerator Nonce generator as defined by [[hashStatements]] to be used if no `nonces` are given. Default produces random UUIDs (v4). + * @param options.hasher The hasher to be used. Required but defaults to 256 bit blake2 over `${nonce}${statement}`. + * @returns An array of salted `hashes` and a `nonceMap` where keys correspond to unsalted statement hashes. + */ +export function hashClaimContents( + claim: PartialClaim, + options: Crypto.HashingOptions & { + canonicalisation?: (claim: PartialClaim) => string[] + } = {} +): { + hashes: HexString[] + nonceMap: Record +} { + // apply defaults + const defaults = { canonicalisation: makeStatementsJsonLD } + const canonicalisation = options.canonicalisation || defaults.canonicalisation + // use canonicalisation algorithm to make hashable statement strings + const statements = canonicalisation(claim) + // iterate over statements to produce salted hashes + const processed = Crypto.hashStatements(statements, options) + // produce array of salted hashes to add to credential + const hashes = processed + .map(({ saltedHash }) => saltedHash) + .sort((a, b) => hexToBn(a).cmp(hexToBn(b))) + // produce nonce map, where each nonce is keyed with the unsalted hash + const nonceMap = {} + processed.forEach(({ digest, nonce, statement }) => { + // throw if we can't map a digest to a nonce - this should not happen if the nonce map is complete and the credential has not been tampered with + if (!nonce) throw new SDKErrors.ClaimNonceMapMalformedError(statement) + nonceMap[digest] = nonce + }, {}) + return { hashes, nonceMap } +} + +/** + * Used to verify the hash list based proof over the set of disclosed attributes in a [[Claim]]. + * + * @param claim Full or partial [[IClaim]] to verify proof against. + * @param proof Proof consisting of a map that matches nonces to statement digests and the resulting hashes. + * @param proof.nonces A map where a statement digest as produces by options.hasher is mapped to a nonce. + * @param proof.hashes Array containing hashes which are signed into the credential. Should result from feeding statement digests and nonces in proof.nonce to options.hasher. + * @param options Object containing optional parameters. + * @param options.canonicalisation Canonicalisation routine that produces an array of statement strings from the [IClaim]. Default produces individual `{"key":"value"}` JSON representations where keys are transformed to expanded JSON-LD. + * @param options.hasher The hasher to be used. Required but defaults to 256 bit blake2 over `${nonce}${statement}`. + */ +export function verifyDisclosedAttributes( + claim: PartialClaim, + proof: { + nonces: Record + hashes: string[] + }, + options: Pick & { + canonicalisation?: (claim: PartialClaim) => string[] + } = {} +): void { + // apply defaults + const defaults = { canonicalisation: makeStatementsJsonLD } + const canonicalisation = options.canonicalisation || defaults.canonicalisation + const { nonces } = proof + // use canonicalisation algorithm to make hashable statement strings + const statements = canonicalisation(claim) + // iterate over statements to produce salted hashes + const hashed = Crypto.hashStatements(statements, { ...options, nonces }) + // check resulting hashes + const digestsInProof = Object.keys(nonces) + const { verified, errors } = hashed.reduce<{ + verified: boolean + errors: Error[] + }>( + (status, { saltedHash, statement, digest, nonce }) => { + // check if the statement digest was contained in the proof and mapped it to a nonce + if (!digestsInProof.includes(digest) || !nonce) { + status.errors.push(new SDKErrors.NoProofForStatementError(statement)) + return { ...status, verified: false } + } + // check if the hash is whitelisted in the proof + if (!proof.hashes.includes(saltedHash)) { + status.errors.push( + new SDKErrors.InvalidProofForStatementError(statement) + ) + return { ...status, verified: false } + } + return status + }, + { verified: true, errors: [] } + ) + if (verified !== true) { + throw new SDKErrors.ClaimUnverifiableError( + 'One or more statements in the claim could not be verified', + { cause: errors } + ) + } +} + +/** + * Checks whether the input meets all the required criteria of an [[IClaim]] object. + * Throws on invalid input. + * + * @param input The potentially only partial IClaim. + */ +export function verifyDataStructure(input: IClaim | PartialClaim): void { + if (!input.cTypeHash) { + throw new SDKErrors.CTypeHashMissingError() + } + if ('owner' in input) { + Did.validateUri(input.owner, 'Did') + } + if (input.contents !== undefined) { + Object.entries(input.contents).forEach(([key, value]) => { + if ( + !key || + typeof key !== 'string' || + !['string', 'number', 'boolean', 'object'].includes(typeof value) + ) { + throw new SDKErrors.ClaimContentsMalformedError() + } + }) + } + DataUtils.verifyIsHex(input.cTypeHash, 256) +} + +/** + * Verifies the data structure and schema of a Claim. + * + * @param claimInput IClaim to verify. + * @param cType ICType to verify claimInput's contents. + */ +export function verify(claimInput: IClaim, cType: ICType): void { + CType.verifyClaimAgainstSchema(claimInput.contents, cType) + verifyDataStructure(claimInput) +} + +/** + * Builds a [[Claim]] from a [[CType]] which has nested [[CType]]s within the schema. + * + * @param cTypeInput A [[CType]] object that has nested [[CType]]s. + * @param nestedCType The array of [[CType]]s, which are used inside the main [[CType]]. + * @param claimContents The data inside the [[Claim]]. + * @param claimOwner The DID of the owner of the [[Claim]]. + * + * @returns A [[Claim]] the owner can use. + */ +export function fromNestedCTypeClaim( + cTypeInput: ICType, + nestedCType: ICType[], + claimContents: IClaim['contents'], + claimOwner: DidUri +): IClaim { + CType.verifyClaimAgainstNestedSchemas(cTypeInput, nestedCType, claimContents) + + const claim = { + cTypeHash: CType.idToHash(cTypeInput.$id), + contents: claimContents, + owner: claimOwner, + } + verifyDataStructure(claim) + return claim +} + +/** + * Constructs a new Claim from the given [[ICType]], IClaim['contents'] and [[DidUri]]. + * + * @param cType [[ICType]] for which the Claim will be built. + * @param claimContents IClaim['contents'] to be used as the pure contents of the instantiated Claim. + * @param claimOwner The DID to be used as the Claim owner. + * @returns A Claim object. + */ +export function fromCTypeAndClaimContents( + cType: ICType, + claimContents: IClaim['contents'], + claimOwner: DidUri +): IClaim { + CType.verifyDataStructure(cType) + CType.verifyClaimAgainstSchema(claimContents, cType) + const claim = { + cTypeHash: CType.idToHash(cType.$id), + contents: claimContents, + owner: claimOwner, + } + verifyDataStructure(claim) + return claim +} + +/** + * Custom Type Guard to determine input being of type IClaim. + * + * @param input The potentially only partial IClaim. + * + * @returns Boolean whether input is of type IClaim. + */ +export function isIClaim(input: unknown): input is IClaim { + try { + verifyDataStructure(input as IClaim) + } catch (error) { + return false + } + return true +} diff --git a/packages/legacy-credentials/src/Credential.spec.ts b/packages/legacy-credentials/src/Credential.spec.ts new file mode 100644 index 000000000..ee66c6f66 --- /dev/null +++ b/packages/legacy-credentials/src/Credential.spec.ts @@ -0,0 +1,958 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable dot-notation */ + +import { randomAsHex } from '@polkadot/util-crypto' + +import { ConfigService } from '@kiltprotocol/config' +import { Attestation, CType, init } from '@kiltprotocol/core' +import * as Did from '@kiltprotocol/did' +import type { + DidDocument, + DidResourceUri, + DidSignature, + DidUri, + DidVerificationKey, + IAttestation, + IClaim, + IClaimContents, + ICredential, + ICredentialPresentation, + ResolvedDidKey, + SignCallback, +} from '@kiltprotocol/types' +import { Crypto, SDKErrors, UUID } from '@kiltprotocol/utils' + +import { + ApiMocks, + createLocalDemoFullDidFromKeypair, + KeyTool, + makeSigningKeyTool, +} from '../../../tests/testUtils' +import * as Claim from './Claim' +import * as Credential from './Credential' + +const testCType = CType.fromProperties('Credential', { + a: { type: 'string' }, + b: { type: 'string' }, + c: { type: 'string' }, +}) + +function buildCredential( + claimerDid: DidUri, + contents: IClaimContents, + legitimations: ICredential[] +): ICredential { + // create claim + + const claim: IClaim = { + cTypeHash: CType.idToHash(testCType.$id), + contents, + owner: claimerDid, + } + // build credential with legitimations + const credential = Credential.fromClaim(claim, { + legitimations, + }) + 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' + const identityBob = + 'did:kilt:4s5d7QHWSX9xx4DLafDtnTHK87n5e9G3UoKRrCDQ2gnrzYmZ' + let legitimation: ICredential + + beforeEach(async () => { + legitimation = buildCredential(identityAlice, {}, []) + }) + + it('verify credential', async () => { + const credential = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation] + ) + // check proof on complete data + expect(() => Credential.verifyDataIntegrity(credential)).not.toThrow() + 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]] + expect(() => Credential.verifyDataIntegrity(credential)).toThrowError( + SDKErrors.ClaimUnverifiableError + ) + }) + + it('throws on wrong hash in claim hash tree', async () => { + const credential = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ) + + credential.claimNonceMap[Object.keys(credential.claimNonceMap)[0]] = '1234' + expect(() => { + Credential.verifyDataIntegrity(credential) + }).toThrow() + }) + + it('hides claim properties', async () => { + const credential = buildCredential(identityBob, { a: 'a', b: 'b' }, []) + const newCredential = Credential.removeClaimProperties(credential, ['a']) + + expect((newCredential.claim.contents as any).a).toBeUndefined() + expect(Object.keys(newCredential.claimNonceMap)).toHaveLength( + newCredential.claimHashes.length - 1 + ) + expect((newCredential.claim.contents as any).b).toBe('b') + expect(() => Credential.verifyDataIntegrity(newCredential)).not.toThrow() + expect(() => Credential.verifyRootHash(newCredential)).not.toThrow() + }) + + it('should throw error on faulty constructor input', async () => { + const builtCredential = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ) + const builtCredentialWithLegitimation = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation] + ) as ICredential + const builtCredentialNoLegitimations = { + ...buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ), + } as ICredential + // @ts-expect-error + delete builtCredentialNoLegitimations.legitimations + + const builtCredentialMalformedRootHash = { + ...buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ), + } as ICredential + // @ts-ignore + builtCredentialMalformedRootHash.rootHash = [ + builtCredentialMalformedRootHash.rootHash.slice(0, 15), + ( + (parseInt(builtCredentialMalformedRootHash.rootHash.charAt(15), 16) + + 1) % + 16 + ).toString(16), + builtCredentialMalformedRootHash.rootHash.slice(16), + ].join('') + const builtCredentialIncompleteClaimHashTree = { + ...buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ), + } as ICredential + const deletedKey = Object.keys( + builtCredentialIncompleteClaimHashTree.claimNonceMap + )[0] + delete builtCredentialIncompleteClaimHashTree.claimNonceMap[deletedKey] + builtCredentialIncompleteClaimHashTree.rootHash = + Credential.calculateRootHash(builtCredentialIncompleteClaimHashTree) + const builtCredentialMalformedSignature = { + ...buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ), + } as ICredentialPresentation + builtCredentialMalformedSignature.claimerSignature = { + signature: Crypto.hashStr('aaa'), + } as DidSignature + builtCredentialMalformedSignature.rootHash = Credential.calculateRootHash( + builtCredentialMalformedSignature + ) + const builtCredentialMalformedHashes = { + ...buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ), + } as ICredential + Object.entries(builtCredentialMalformedHashes.claimNonceMap).forEach( + ([hash, nonce]) => { + const scrambledHash = [ + hash.slice(0, 15), + ((parseInt(hash.charAt(15), 16) + 1) % 16).toString(16), + hash.slice(16), + ].join('') + builtCredentialMalformedHashes.claimNonceMap[scrambledHash] = nonce + delete builtCredentialMalformedHashes.claimNonceMap[hash] + } + ) + builtCredentialMalformedHashes.rootHash = Credential.calculateRootHash( + builtCredentialMalformedHashes + ) + expect(() => + Credential.verifyDataStructure(builtCredentialNoLegitimations) + ).toThrowError(SDKErrors.LegitimationsMissingError) + expect(() => + Credential.verifyDataIntegrity(builtCredentialMalformedRootHash) + ).toThrowError(SDKErrors.RootHashUnverifiableError) + expect(() => + Credential.verifyDataIntegrity(builtCredentialIncompleteClaimHashTree) + ).toThrowError(SDKErrors.ClaimUnverifiableError) + expect(Credential.isPresentation(builtCredentialMalformedSignature)).toBe( + false + ) + expect(() => + Credential.verifyDataIntegrity(builtCredentialMalformedHashes) + ).toThrowError(SDKErrors.ClaimUnverifiableError) + expect(() => Credential.verifyDataStructure(builtCredential)).not.toThrow() + expect(() => { + Credential.verifyDataStructure(builtCredentialWithLegitimation) + }).not.toThrow() + expect(() => Credential.verifyDataIntegrity(builtCredential)).not.toThrow() + expect(() => { + Credential.verifyDataIntegrity(builtCredentialWithLegitimation) + }).not.toThrow() + }) + it('checks Object instantiation', async () => { + const builtCredential = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ) + expect(Credential.isICredential(builtCredential)).toEqual(true) + }) + + it('should verify the credential claims structure against the ctype', async () => { + const builtCredential = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 'c', + }, + [] + ) + expect(() => + Credential.verifyWellFormed(builtCredential, { ctype: testCType }) + ).not.toThrow() + const builtCredentialWrong = buildCredential( + identityBob, + { + a: 'a', + b: 'b', + c: 1, + }, + [] + ) + expect(() => + Credential.verifyWellFormed(builtCredentialWrong, { ctype: testCType }) + ).toThrow() + }) + + it('two Credentials on an empty ctype will have different root hashes', async () => { + const ctype = CType.fromProperties('CType', {}) + const claimA1 = Claim.fromCTypeAndClaimContents(ctype, {}, identityAlice) + const claimA2 = Claim.fromCTypeAndClaimContents(ctype, {}, identityAlice) + + expect(Credential.fromClaim(claimA1).rootHash).not.toEqual( + 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', () => { + let keyAlice: KeyTool + let keyCharlie: KeyTool + let identityAlice: DidDocument + let identityBob: DidDocument + let identityCharlie: DidDocument + let legitimation: ICredentialPresentation + let identityDave: DidDocument + let migratedAndDeletedLightDid: DidDocument + + async function didResolveKey( + keyUri: DidResourceUri + ): Promise { + const { did } = Did.parse(keyUri) + const document = [ + identityAlice, + identityBob, + identityCharlie, + identityDave, + ].find(({ uri }) => uri === did) + if (!document) throw new Error('Cannot resolve mocked DID') + return Did.keyToResolvedKey(document.authentication[0], did) + } + + // TODO: Cleanup file by migrating setup functions and removing duplicate tests. + async function buildPresentation( + claimer: DidDocument, + attesterDid: DidUri, + contents: IClaim['contents'], + legitimations: ICredential[], + sign: SignCallback + ): Promise<[ICredentialPresentation, IAttestation]> { + // create claim + const claim = Claim.fromCTypeAndClaimContents( + testCType, + contents, + claimer.uri + ) + // build credential with legitimations + const credential = Credential.fromClaim(claim, { + legitimations, + }) + const presentation = await Credential.createPresentation({ + credential, + signCallback: sign, + }) + // build attestation + const testAttestation = Attestation.fromCredentialAndDid( + credential, + attesterDid + ) + return [presentation, testAttestation] + } + + beforeAll(async () => { + keyAlice = makeSigningKeyTool() + identityAlice = await createLocalDemoFullDidFromKeypair(keyAlice.keypair) + + const keyBob = makeSigningKeyTool() + identityBob = await createLocalDemoFullDidFromKeypair(keyBob.keypair) + + keyCharlie = makeSigningKeyTool() + identityCharlie = await createLocalDemoFullDidFromKeypair( + keyCharlie.keypair + ) + ;[legitimation] = await buildPresentation( + identityAlice, + identityBob.uri, + {}, + [], + 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 () => { + const [presentation] = await buildPresentation( + identityCharlie, + identityAlice.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation], + keyCharlie.getSignCallback(identityCharlie) + ) + + // check proof on complete data + expect(() => Credential.verifyDataIntegrity(presentation)).not.toThrow() + 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') + identityDave = Did.createLightDidDocument({ + authentication, + }) + + const [presentation] = await buildPresentation( + identityDave, + identityAlice.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation], + getSignCallback(identityDave) + ) + + // check proof on complete data + expect(() => Credential.verifyDataIntegrity(presentation)).not.toThrow() + await expect( + Credential.verifyPresentation(presentation, { + didResolveKey, + }) + ).resolves.toMatchObject({ revoked: false, attester: identityBob.uri }) + }) + + it('throws if signature is missing on credential presentation', async () => { + const credential = buildCredential( + identityBob.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation] + ) + await expect( + Credential.verifyPresentation(credential as ICredentialPresentation, { + ctype: testCType, + didResolveKey, + }) + ).rejects.toThrow() + }) + + it('throws if signature is by unrelated did', async () => { + const { getSignCallback, authentication } = makeSigningKeyTool('ed25519') + identityDave = Did.createLightDidDocument({ + authentication, + }) + + const credential = buildCredential( + identityBob.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation] + ) + + const presentation = await Credential.createPresentation({ + credential, + signCallback: getSignCallback(identityDave), + }) + + await expect( + Credential.verifySignature(presentation, { + didResolveKey, + }) + ).rejects.toThrow(SDKErrors.DidSubjectMismatchError) + }) + + it('throws if signature is by corresponding light did', async () => { + // make mock resolver resolve corresponding light did by assigning it to dave identity + identityDave = Did.createLightDidDocument({ + authentication: keyAlice.authentication, + }) + + const credential = buildCredential( + identityAlice.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation] + ) + + // sign presentation using Alice's authenication key + const presentation = await Credential.createPresentation({ + credential, + signCallback: keyAlice.getSignCallback(identityAlice), + }) + // but replace signer key reference with authentication key of light did + presentation.claimerSignature.keyUri = `${identityDave.uri}${identityDave.authentication[0].id}` + + // signature would check out but mismatch should be detected + await expect( + Credential.verifySignature(presentation, { + didResolveKey, + }) + ).rejects.toThrow(SDKErrors.DidSubjectMismatchError) + }) + + it('fail to verify credentials signed by a light DID after it has been migrated and deleted', async () => { + const migratedAndDeleted = makeSigningKeyTool('ed25519') + migratedAndDeletedLightDid = Did.createLightDidDocument({ + authentication: migratedAndDeleted.authentication, + }) + + const [presentation] = await buildPresentation( + migratedAndDeletedLightDid, + identityAlice.uri, + { + a: 'a', + b: 'b', + c: 'c', + }, + [legitimation], + migratedAndDeleted.getSignCallback(migratedAndDeletedLightDid) + ) + + // check proof on complete data + expect(() => Credential.verifyDataIntegrity(presentation)).not.toThrow() + await expect( + Credential.verifyPresentation(presentation, { + didResolveKey, + }) + ).rejects.toThrowError() + }) + + it('Typeguard should return true on complete Credentials', async () => { + const [presentation] = await buildPresentation( + identityAlice, + identityBob.uri, + {}, + [], + keyAlice.getSignCallback(identityAlice) + ) + expect(Credential.isICredential(presentation)).toBe(true) + delete (presentation as Partial).claimHashes + + expect(Credential.isICredential(presentation)).toBe(false) + }) + it('Should throw error when attestation is from different credential', async () => { + const [credential, attestation] = await buildPresentation( + identityAlice, + identityBob.uri, + {}, + [], + keyAlice.getSignCallback(identityAlice) + ) + expect(() => + Attestation.verifyAgainstCredential(attestation, credential) + ).not.toThrow() + const { cTypeHash } = attestation + // @ts-ignore + attestation.cTypeHash = [ + cTypeHash.slice(0, 15), + ((parseInt(cTypeHash.charAt(15), 16) + 1) % 16).toString(16), + cTypeHash.slice(16), + ].join('') + expect(() => + Attestation.verifyAgainstCredential(attestation, credential) + ).toThrow() + }) + it('returns Claim Hash of the attestation', async () => { + const [credential, attestation] = await buildPresentation( + identityAlice, + identityBob.uri, + {}, + [], + keyAlice.getSignCallback(identityAlice) + ) + expect(Credential.getHash(credential)).toEqual(attestation.claimHash) + }) +}) + +describe('create presentation', () => { + let migratedClaimerLightDid: DidDocument + let migratedClaimerFullDid: DidDocument + let newKeyForMigratedClaimerDid: KeyTool + let unmigratedClaimerLightDid: DidDocument + let unmigratedClaimerKey: KeyTool + let migratedThenDeletedClaimerLightDid: DidDocument + let migratedThenDeletedKey: KeyTool + let attester: DidDocument + let credential: ICredential + + const ctype = CType.fromProperties('otherCType', { + name: { type: 'string' }, + age: { type: 'number' }, + }) + + // Returns a full DID that has the same subject of the first light DID, but the same key authentication key as the second one, if provided, or as the first one otherwise. + function createMinimalFullDidFromLightDid( + lightDidForId: DidDocument, + newAuthenticationKey?: DidVerificationKey + ): DidDocument { + const uri = Did.getFullDidUri(lightDidForId.uri) + const authKey = newAuthenticationKey || lightDidForId.authentication[0] + + return { + uri, + authentication: [authKey], + } + } + + async function didResolveKey( + keyUri: DidResourceUri + ): Promise { + const { did } = Did.parse(keyUri) + const document = [ + migratedClaimerLightDid, + unmigratedClaimerLightDid, + migratedClaimerFullDid, + attester, + ].find(({ uri }) => uri === did) + if (!document) throw new Error('Cannot resolve mocked DID') + return Did.keyToResolvedKey(document.authentication[0], did) + } + + beforeAll(async () => { + const { keypair } = makeSigningKeyTool() + attester = await createLocalDemoFullDidFromKeypair(keypair) + + unmigratedClaimerKey = makeSigningKeyTool() + unmigratedClaimerLightDid = Did.createLightDidDocument({ + authentication: unmigratedClaimerKey.authentication, + }) + const migratedClaimerKey = makeSigningKeyTool() + migratedClaimerLightDid = Did.createLightDidDocument({ + authentication: migratedClaimerKey.authentication, + }) + // Change also the authentication key of the full DID to properly verify signature verification, + // so that it uses a completely different key and the credential is still correctly verified. + newKeyForMigratedClaimerDid = makeSigningKeyTool() + migratedClaimerFullDid = createMinimalFullDidFromLightDid( + migratedClaimerLightDid, + { + ...newKeyForMigratedClaimerDid.authentication[0], + id: '#new-auth', + } + ) + migratedThenDeletedKey = makeSigningKeyTool('ed25519') + migratedThenDeletedClaimerLightDid = Did.createLightDidDocument({ + authentication: migratedThenDeletedKey.authentication, + }) + + // cannot be used since the variable needs to be established in the outer scope + credential = Credential.fromClaim( + Claim.fromCTypeAndClaimContents( + ctype, + { + name: 'Peter', + age: 12, + }, + 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 () => { + const challenge = UUID.generate() + const presentation = await Credential.createPresentation({ + credential, + selectedAttributes: ['name'], + signCallback: newKeyForMigratedClaimerDid.getSignCallback( + migratedClaimerFullDid + ), + challenge, + }) + 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 () => { + // cannot be used since the variable needs to be established in the outer scope + credential = Credential.fromClaim( + Claim.fromCTypeAndClaimContents( + ctype, + { + name: 'Peter', + age: 12, + }, + unmigratedClaimerLightDid.uri + ) + ) + + const challenge = UUID.generate() + const presentation = await Credential.createPresentation({ + credential, + selectedAttributes: ['name'], + signCallback: unmigratedClaimerKey.getSignCallback( + unmigratedClaimerLightDid + ), + challenge, + }) + 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 () => { + // cannot be used since the variable needs to be established in the outer scope + credential = Credential.fromClaim( + Claim.fromCTypeAndClaimContents( + ctype, + { + name: 'Peter', + age: 12, + }, + // Use of light DID in the claim. + migratedClaimerLightDid.uri + ) + ) + + const challenge = UUID.generate() + const presentation = await Credential.createPresentation({ + credential, + selectedAttributes: ['name'], + // Use of full DID to sign the presentation. + signCallback: newKeyForMigratedClaimerDid.getSignCallback( + migratedClaimerFullDid + ), + challenge, + }) + await expect( + Credential.verifyPresentation(presentation, { + didResolveKey, + }) + ).resolves.toMatchObject({ revoked: false, attester: attester.uri }) + expect(presentation.claimerSignature?.challenge).toEqual(challenge) + }) + + it('should fail to create a valid presentation and exclude specific attributes using a light DID after it has been migrated', async () => { + // cannot be used since the variable needs to be established in the outer scope + credential = Credential.fromClaim( + Claim.fromCTypeAndClaimContents( + ctype, + { + name: 'Peter', + age: 12, + }, + // Use of light DID in the claim. + migratedClaimerLightDid.uri + ) + ) + + const challenge = UUID.generate() + const att = await Credential.createPresentation({ + credential, + selectedAttributes: ['name'], + // Still using the light DID, which should fail since it has been migrated + signCallback: newKeyForMigratedClaimerDid.getSignCallback( + migratedClaimerLightDid + ), + challenge, + }) + await expect( + Credential.verifyPresentation(att, { + didResolveKey, + }) + ).rejects.toThrow() + }) + + it('should fail to create a valid presentation using a light DID after it has been migrated and deleted', async () => { + // cannot be used since the variable needs to be established in the outer scope + credential = Credential.fromClaim( + Claim.fromCTypeAndClaimContents( + ctype, + { + name: 'Peter', + age: 12, + }, + // Use of light DID in the claim. + migratedThenDeletedClaimerLightDid.uri + ) + ) + + const challenge = UUID.generate() + const presentation = await Credential.createPresentation({ + credential, + selectedAttributes: ['name'], + // Still using the light DID, which should fail since it has been migrated and then deleted + signCallback: migratedThenDeletedKey.getSignCallback( + migratedThenDeletedClaimerLightDid + ), + challenge, + }) + await expect( + Credential.verifyPresentation(presentation, { + didResolveKey, + }) + ).rejects.toThrow() + }) + + it('should verify the credential claims structure against the ctype', () => { + expect(() => + CType.verifyClaimAgainstSchema(credential.claim.contents, ctype) + ).not.toThrow() + credential.claim.contents.name = 123 + + expect(() => + CType.verifyClaimAgainstSchema(credential.claim.contents, ctype) + ).toThrow() + }) +}) diff --git a/packages/legacy-credentials/src/Credential.ts b/packages/legacy-credentials/src/Credential.ts new file mode 100644 index 000000000..316fcb310 --- /dev/null +++ b/packages/legacy-credentials/src/Credential.ts @@ -0,0 +1,518 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +/** + * Credentials are a core building block of the KILT SDK. + * A Credential represents a [[Claim]] which needs to be validated. In practice, the Credential is sent from a claimer to an attester for attesting and to a verifier for verification. + * + * A Credential object contains the [[Claim]] and its hash, and legitimations/delegationId of the attester. + * The credential is made tamper-proof by hashing the claim properties and generating a digest from that, which is used to reference the Credential. + * It can be signed by the claimer, to authenticate the holder and to prevent replay attacks. + * A Credential also supports hiding of claim data during a credential presentation. + * + * @packageDocumentation + */ + +import { ConfigService } from '@kiltprotocol/config' +import { Attestation, CType } from '@kiltprotocol/core' +import { + isDidSignature, + resolveKey, + signatureFromJson, + signatureToJson, + verifyDidSignature, +} from '@kiltprotocol/did' +import type { + DidResolveKey, + DidUri, + Hash, + IAttestation, + ICType, + IClaim, + ICredential, + ICredentialPresentation, + IDelegationNode, + SignCallback, +} from '@kiltprotocol/types' +import { Crypto, DataUtils, SDKErrors } from '@kiltprotocol/utils' +import * as Claim from './Claim.js' +import { hashClaimContents } from './Claim.js' + +function getHashRoot(leaves: Uint8Array[]): Uint8Array { + const result = Crypto.u8aConcat(...leaves) + return Crypto.hash(result) +} + +function getHashLeaves( + claimHashes: Hash[], + legitimations?: ICredential[], + delegationId?: IDelegationNode['id'] | null +): Uint8Array[] { + const result = claimHashes.map((item) => Crypto.coToUInt8(item)) + if (legitimations) { + legitimations.forEach((legitimation) => { + result.push(Crypto.coToUInt8(legitimation.rootHash)) + }) + } + if (delegationId) { + result.push(Crypto.coToUInt8(delegationId)) + } + + return result +} + +/** + * Calculates the root hash of the credential. + * + * @param credential The credential object. + * @returns The hash. + */ +export function calculateRootHash(credential: Partial): Hash { + const hashes = getHashLeaves( + credential.claimHashes || [], + credential.legitimations || [], + credential.delegationId || null + ) + const root = getHashRoot(hashes) + return Crypto.u8aToHex(root) +} + +/** + * Removes [[Claim]] properties from the [[Credential]] object, provides anonymity and security when building the [[createPresentation]] method. + * + * @param credential - The Credential object to remove properties from. + * @param properties - Properties to remove from the [[Claim]] object. + * @returns A cloned Credential with removed properties. + */ +export function removeClaimProperties( + credential: ICredential, + properties: string[] +): ICredential { + const presentation: ICredential = + // clone the credential because properties will be deleted later. + // TODO: find a nice way to clone stuff + JSON.parse(JSON.stringify(credential)) + + properties.forEach((key) => { + delete presentation.claim.contents[key] + }) + presentation.claimNonceMap = hashClaimContents(presentation.claim, { + nonces: presentation.claimNonceMap, + }).nonceMap + + return presentation +} + +/** + * Prepares credential data for signing. + * + * @param input - The Credential to prepare the data for. + * @param challenge - An optional challenge to be included in the signing process. + * @returns The prepared signing data as Uint8Array. + */ +export function makeSigningData( + input: ICredential, + challenge?: string +): Uint8Array { + return new Uint8Array([ + ...Crypto.coToUInt8(input.rootHash), + ...Crypto.coToUInt8(challenge), + ]) +} + +/** + * Verifies if the credential hash matches the contents of it. + * + * @param input - The credential to check. + */ +export function verifyRootHash(input: ICredential): void { + if (input.rootHash !== calculateRootHash(input)) { + throw new SDKErrors.RootHashUnverifiableError() + } +} + +/** + * Verifies the data of the [[Credential]] object; used to check that the data was not tampered with, + * by checking the data against hashes. Throws if invalid. + * + * @param input - The [[Credential]] for which to verify data. + */ +export function verifyDataIntegrity(input: ICredential): void { + // check claim hash + verifyRootHash(input) + + // verify properties against selective disclosure proof + Claim.verifyDisclosedAttributes(input.claim, { + nonces: input.claimNonceMap, + hashes: input.claimHashes, + }) + + // check legitimations + input.legitimations.forEach(verifyDataIntegrity) +} + +/** + * Checks whether the input meets all the required criteria of an [[ICredential]] object. + * Throws on invalid input. + * + * @param input - A potentially only partial [[Credential]]. + * + */ +export function verifyDataStructure(input: ICredential): void { + if (!('claim' in input)) { + throw new SDKErrors.ClaimMissingError() + } else { + Claim.verifyDataStructure(input.claim) + } + if (!input.claim.owner) { + throw new SDKErrors.OwnerMissingError() + } + if (!Array.isArray(input.legitimations)) { + throw new SDKErrors.LegitimationsMissingError() + } + + if (!('claimNonceMap' in input)) { + throw new SDKErrors.ClaimNonceMapMissingError() + } + if (typeof input.claimNonceMap !== 'object') { + throw new SDKErrors.ClaimNonceMapMalformedError() + } + Object.entries(input.claimNonceMap).forEach(([digest, nonce]) => { + DataUtils.verifyIsHex(digest, 256) + if (!digest || typeof nonce !== 'string' || !nonce) { + throw new SDKErrors.ClaimNonceMapMalformedError() + } + }) + + if (!('claimHashes' in input)) { + throw new SDKErrors.DataStructureError('claim hashes not provided') + } + + if (typeof input.delegationId !== 'string' && input.delegationId !== null) { + throw new SDKErrors.DelegationIdTypeError() + } +} + +/** + * Verifies the signature of the [[ICredentialPresentation]]. + * It supports migrated DIDs, meaning that if the original claim within the [[ICredential]] included a light DID that was afterwards upgraded, + * the signature over the presentation **must** be generated with the full DID in order for the verification to be successful. + * On the other hand, a light DID that has been migrated and then deleted from the chain will not be allowed to generate valid presentations anymore. + * + * @param input - The [[ICredentialPresentation]]. + * @param verificationOpts Additional verification options. + * @param verificationOpts.didResolveKey - The function used to resolve the claimer's key. Defaults to [[resolveKey]]. + * @param verificationOpts.challenge - The expected value of the challenge. Verification will fail in case of a mismatch. + */ +export async function verifySignature( + input: ICredentialPresentation, + { + challenge, + didResolveKey = resolveKey, + }: { + challenge?: string + didResolveKey?: DidResolveKey + } = {} +): Promise { + const { claimerSignature } = input + if (challenge && challenge !== claimerSignature.challenge) { + throw new SDKErrors.SignatureUnverifiableError( + 'Challenge differs from expected' + ) + } + const signingData = makeSigningData(input, claimerSignature.challenge) + await verifyDidSignature({ + ...signatureFromJson(claimerSignature), + message: signingData, + // check if credential owner matches signer + expectedSigner: input.claim.owner, + // allow full did to sign presentation if owned by corresponding light did + allowUpgraded: true, + expectedVerificationMethod: 'authentication', + didResolveKey, + }) +} + +export type Options = { + legitimations?: ICredential[] + delegationId?: IDelegationNode['id'] | null +} + +/** + * Builds a new [[ICredential]] object, from a complete set of required parameters. + * + * @param claim An [[IClaim]] object to build the credential for. + * @param option Container for different options that can be passed to this method. + * @param option.legitimations Array of [[Credential]] objects of the Attester which the Claimer requests to include into the attestation as legitimations. + * @param option.delegationId The id of the DelegationNode of the Attester, which should be used in the attestation. + * @returns A new [[ICredential]] object. + */ +export function fromClaim( + claim: IClaim, + { legitimations = [], delegationId = null }: Options = {} +): ICredential { + const { hashes: claimHashes, nonceMap: claimNonceMap } = + Claim.hashClaimContents(claim) + + const rootHash = calculateRootHash({ + legitimations, + claimHashes, + delegationId, + }) + + const credential = { + claim, + legitimations, + claimHashes, + claimNonceMap, + rootHash, + delegationId, + } + verifyDataStructure(credential) + return credential +} + +type VerifyOptions = { + ctype?: ICType + challenge?: string + didResolveKey?: DidResolveKey +} + +/** + * Verifies data structure & data integrity of a credential object. + * This combines all offline sanity checks that can be performed on an ICredential object. + * A credential is valid only if it is well formed AND there is an on-chain attestation record referencing its root hash. + * To check the latter condition as well, you need to call [[verifyCredential]] or [[verifyPresentation]]. + * + * @param credential - The object to check. + * @param options - Additional parameter for more verification steps. + * @param options.ctype - CType which the included claim should be checked against. + */ +export function verifyWellFormed( + credential: ICredential, + { ctype }: VerifyOptions = {} +): void { + verifyDataStructure(credential) + verifyDataIntegrity(credential) + + if (ctype) { + CType.verifyClaimAgainstSchema(credential.claim.contents, ctype) + } +} + +/** + * Queries the attestation record for a credential and matches their data. Fails if no attestation exists, if it is revoked, or if the attestation data does not match the credential. + * + * @param credential The [[ICredential]] whose attestation status should be checked. + * @returns An object containing the `attester` DID and `revoked` status of the on-chain attestation. + */ +export async function verifyAttested(credential: ICredential): Promise<{ + attester: DidUri + revoked: boolean +}> { + const api = ConfigService.get('api') + const { rootHash } = credential + const maybeAttestation = await api.query.attestation.attestations(rootHash) + if (maybeAttestation.isNone) { + throw new SDKErrors.CredentialUnverifiableError('Attestation not found') + } + const attestation = Attestation.fromChain( + maybeAttestation, + credential.rootHash + ) + Attestation.verifyAgainstCredential(attestation, credential) + const { owner: attester, revoked } = attestation + return { attester, revoked } +} + +export interface VerifiedCredential extends ICredential { + revoked: boolean + attester: DidUri +} + +/** + * Updates the revocation status of a previously verified credential to allow checking if it is still valid. + * + * @param verifiedCredential The output of [[verifyCredential]] or [[verifyPresentation]], which adds a `revoked` and `attester` property. + * @returns A promise of resolving to the same object but with the `revoked` property updated. + * The promise rejects if the attestation has been deleted or its data changed since verification. + */ +export async function refreshRevocationStatus( + verifiedCredential: VerifiedCredential +): Promise { + if ( + typeof verifiedCredential.attester !== 'string' || + typeof verifiedCredential.revoked !== 'boolean' + ) { + throw new TypeError( + 'This function expects a VerifiedCredential with properties `revoked` (boolean) and `attester` (string)' + ) + } + const { revoked, attester } = await verifyAttested(verifiedCredential) + if (attester !== verifiedCredential.attester) { + throw new SDKErrors.CredentialUnverifiableError( + 'Attester has changed since first verification' + ) + } + return { ...verifiedCredential, revoked } +} + +/** + * Performs all steps to verify a credential (unsigned), which includes verifying data structure, data integrity, and looking up its attestation on the KILT blockchain. + * In most cases, credentials submitted by a third party would be expected to be signed (a 'presentation'). + * To verify the additional signature as well, use `verifyPresentation`. + * + * @param credential - The object to check. + * @param options - Additional parameter for more verification steps. + * @param options.ctype - CType which the included claim should be checked against. + * @returns A [[VerifiedCredential]] object, which is the orignal credential with two additional properties: a boolean `revoked` status flag and the `attester` DID. + */ +export async function verifyCredential( + credential: ICredential, + { ctype }: VerifyOptions = {} +): Promise { + verifyWellFormed(credential, { ctype }) + const { revoked, attester } = await verifyAttested(credential) + return { + ...credential, + revoked, + attester, + } +} + +/** + * Performs all steps to verify a credential presentation (signed). + * In addition to verifying data structure, data integrity, and looking up the attestation record on the KILT blockchain, + * this involves verifying the claimer's signature over the credential. + * + * This is the function verifiers would typically call upon receiving a credential presentation from a third party. + * The attester's identity and the credential revocation status returned by this function would then be either displayed to an end user + * or processed in application logic deciding whether to accept or reject a credential submission + * (e.g., by matching the attester DID against an allow-list of trusted attesters). + * + * @param presentation - The object to check. + * @param options - Additional parameter for more verification steps. + * @param options.ctype - CType which the included claim should be checked against. + * @param options.challenge - The expected value of the challenge. Verification will fail in case of a mismatch. + * @param options.didResolveKey - The function used to resolve the claimer's key. Defaults to [[resolveKey]]. + * @returns A [[VerifiedCredential]] object, which is the orignal credential presentation with two additional properties: + * a boolean `revoked` status flag and the `attester` DID. + */ +export async function verifyPresentation( + presentation: ICredentialPresentation, + { ctype, challenge, didResolveKey = resolveKey }: VerifyOptions = {} +): Promise { + await verifySignature(presentation, { + challenge, + didResolveKey, + }) + return verifyCredential(presentation, { ctype }) +} + +/** + * Type Guard to determine input being of type [[ICredential]]. + * + * @param input - A potentially only partial [[ICredential]]. + * + * @returns Boolean whether input is of type ICredential. + */ +export function isICredential(input: unknown): input is ICredential { + try { + verifyDataStructure(input as ICredential) + } catch (error) { + return false + } + return true +} + +/** + * Type Guard to determine input being of type [[ICredentialPresentation]]. + * + * @param input - An [[ICredential]], [[ICredentialPresentation]], or other object. + * + * @returns Boolean whether input is of type ICredentialPresentation. + */ +export function isPresentation( + input: unknown +): input is ICredentialPresentation { + return ( + isICredential(input) && + isDidSignature((input as ICredentialPresentation).claimerSignature) + ) +} + +/** + * Gets the hash of the credential. + * + * @param credential - The credential to get the hash from. + * @returns The hash of the credential. + */ +export function getHash(credential: ICredential): IAttestation['claimHash'] { + return credential.rootHash +} + +/** + * Gets names of the credential’s attributes. + * + * @param credential The credential. + * @returns The set of names. + */ +function getAttributes(credential: ICredential): Set { + // TODO: move this to claim or contents + return new Set(Object.keys(credential.claim.contents)) +} + +/** + * Creates a public presentation which can be sent to a verifier. + * This presentation is signed. + * + * @param presentationOptions The additional options to use upon presentation generation. + * @param presentationOptions.credential The credential to create the presentation for. + * @param presentationOptions.signCallback The callback to sign the presentation. + * @param presentationOptions.selectedAttributes All properties of the claim which have been requested by the verifier and therefore must be publicly presented. + * @param presentationOptions.challenge Challenge which will be part of the presentation signature. + * If not specified, all attributes are shown. If set to an empty array, we hide all attributes inside the claim for the presentation. + * @returns A deep copy of the Credential with all but `publicAttributes` removed. + */ +export async function createPresentation({ + credential, + signCallback, + selectedAttributes, + challenge, +}: { + credential: ICredential + signCallback: SignCallback + selectedAttributes?: string[] + challenge?: string +}): Promise { + // filter attributes that are not in public attributes + const excludedClaimProperties = selectedAttributes + ? Array.from(getAttributes(credential)).filter( + (property) => !selectedAttributes.includes(property) + ) + : [] + + // remove these attributes + const presentation = removeClaimProperties( + credential, + excludedClaimProperties + ) + + const signature = await signCallback({ + data: makeSigningData(presentation, challenge), + did: credential.claim.owner, + keyRelationship: 'authentication', + }) + + return { + ...presentation, + claimerSignature: { + ...signatureToJson(signature), + ...(challenge && { challenge }), + }, + } +} diff --git a/packages/legacy-credentials/src/index.ts b/packages/legacy-credentials/src/index.ts new file mode 100644 index 000000000..4cb66e629 --- /dev/null +++ b/packages/legacy-credentials/src/index.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +export { fromVC, toVc } from './vcInterop.js' +export * as Claim from './Claim.js' +export * as Credential from './Credential.js' diff --git a/packages/legacy-credentials/src/utils.ts b/packages/legacy-credentials/src/utils.ts new file mode 100644 index 000000000..9d767fb48 --- /dev/null +++ b/packages/legacy-credentials/src/utils.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +import { CType } from '@kiltprotocol/core' +import { PartialClaim } from '@kiltprotocol/types' +import { SDKErrors } from '@kiltprotocol/utils' + +/** + * Produces JSON-LD readable representations of [[IClaim]]['contents']. This is done by implicitly or explicitly transforming property keys to globally unique predicates. + * Where possible these predicates are taken directly from the Verifiable Credentials vocabulary. Properties that are unique to a [[CType]] are transformed into predicates by prepending the [[CType]][schema][$id]. + * + * @param claim A (partial) [[IClaim]] from to build a JSON-LD representation from. The `cTypeHash` property is required. + * @param expanded Return an expanded instead of a compacted representation. While property transformation is done explicitly in the expanded format, it is otherwise done implicitly via adding JSON-LD's reserved `@context` properties while leaving [[IClaim]][contents] property keys untouched. + * @returns An object which can be serialized into valid JSON-LD representing an [[IClaim]]'s ['contents']. + */ +export function jsonLDcontents( + claim: PartialClaim, + expanded = true +): Record { + const { cTypeHash, contents, owner } = claim + if (!cTypeHash) { + throw new SDKErrors.CTypeHashMissingError() + } + const vocabulary = `${CType.hashToId(cTypeHash)}#` + const result: Record = {} + if (owner) { + result['@id'] = owner + } + if (!expanded) { + if (contents && ('@context' in contents || '@id' in contents)) { + throw new Error( + 'This claim contains @-prefixed restricted properties and thus cannot be properly expressed as JSON-LD' + ) + } + return { + ...contents, + ...result, + '@context': { '@vocab': vocabulary }, + } + } + Object.entries(contents || {}).forEach(([key, value]) => { + result[vocabulary + key] = value + }) + return result +} + +/** + * Produces canonical statements for selective disclosure based on a JSON-LD expanded representation of the claims. + * + * @param claim A (partial) [[IClaim]] from to build a JSON-LD representation from. The `cTypeHash` property is required. + * @returns An array of stringified statements. + */ +export function makeStatementsJsonLD(claim: PartialClaim): string[] { + const normalized = jsonLDcontents(claim, true) + return Object.entries(normalized).map(([key, value]) => + JSON.stringify({ [key]: value }) + ) +} diff --git a/packages/vc-export/src/exportToVerifiableCredential.spec.ts b/packages/legacy-credentials/src/vcInterop.spec.ts similarity index 76% rename from packages/vc-export/src/exportToVerifiableCredential.spec.ts rename to packages/legacy-credentials/src/vcInterop.spec.ts index e5eb3862b..301a541f2 100644 --- a/packages/vc-export/src/exportToVerifiableCredential.spec.ts +++ b/packages/legacy-credentials/src/vcInterop.spec.ts @@ -5,23 +5,22 @@ * found in the LICENSE file in the root directory of this source tree. */ -import { hexToU8a, u8aConcat, u8aToU8a } from '@polkadot/util' +import { u8aConcat, u8aToU8a } from '@polkadot/util' import { randomAsU8a } from '@polkadot/util-crypto' -import { Credential } from '@kiltprotocol/core' import type { IAttestation, ICType, ICredential } from '@kiltprotocol/types' +import { + KiltAttestationProofV1, + KiltCredentialV1, + constants, +} from '@kiltprotocol/vc-export' import { ApiMocks } from '../../../tests/testUtils' -import { - credentialSchema, - validateStructure as validateCredentialStructure, -} from './KiltCredentialV1' -import { credentialIdFromRootHash } from './common' -import { - DEFAULT_CREDENTIAL_CONTEXTS, - DEFAULT_CREDENTIAL_TYPES, -} from './constants' -import { exportICredentialToVc } from './fromICredential' +import { calculateRootHash, removeClaimProperties } from './Credential' +import { fromVC, toVc } from './vcInterop' + +// is not needed and imports a dependency that does not work in node 18 +jest.mock('@digitalbazaar/http-client', () => ({})) export const mockedApi = ApiMocks.createAugmentedApi() @@ -95,7 +94,7 @@ export const credential: ICredential = { '0xb102f462e4cde1b48e7936085cef1e2ab6ae4f7ca46cd3fab06074c00546a33d', rootHash: '0x', } -credential.rootHash = Credential.calculateRootHash(credential) +credential.rootHash = calculateRootHash(credential) export const attestation: IAttestation = { claimHash: credential.rootHash, @@ -149,44 +148,42 @@ mockedApi.query.system = { } as any it('exports credential to VC', () => { - const exported = exportICredentialToVc(credential, { + const exported = toVc(credential, { issuer: attestation.owner, chainGenesisHash: mockedApi.genesisHash, blockHash, timestamp, - cType: cType.$id, }) expect(exported).toMatchObject({ - '@context': DEFAULT_CREDENTIAL_CONTEXTS, - type: [...DEFAULT_CREDENTIAL_TYPES, cType.$id], + '@context': constants.DEFAULT_CREDENTIAL_CONTEXTS, + type: [...constants.DEFAULT_CREDENTIAL_TYPES, cType.$id], credentialSubject: { id: 'did:kilt:4r1WkS3t8rbCb11H8t3tJvGVCynwDXSUBiuGB6sLRHzCLCjs', birthday: '1991-01-01', name: 'Kurt', premium: true, }, - id: credentialIdFromRootHash(hexToU8a(credential.rootHash)), + id: KiltCredentialV1.idFromRootHash(credential.rootHash), issuanceDate: expect.any(String), issuer: 'did:kilt:4sejigvu6STHdYmmYf2SuN92aNp8TbrsnBBDUj7tMrJ9Z3cG', nonTransferable: true, }) - expect(() => validateCredentialStructure(exported)).not.toThrow() + expect(() => KiltCredentialV1.validateStructure(exported)).not.toThrow() }) it('VC has correct format (full example)', () => { expect( - exportICredentialToVc(credential, { + toVc(credential, { issuer: attestation.owner, chainGenesisHash: mockedApi.genesisHash, blockHash, timestamp, - cType: cType.$id, }) ).toMatchObject({ - '@context': DEFAULT_CREDENTIAL_CONTEXTS, - type: [...DEFAULT_CREDENTIAL_TYPES, cType.$id], + '@context': constants.DEFAULT_CREDENTIAL_CONTEXTS, + type: [...constants.DEFAULT_CREDENTIAL_TYPES, cType.$id], credentialSchema: { - id: credentialSchema.$id, + id: KiltCredentialV1.credentialSchema.$id, type: 'JsonSchema2023', }, credentialSubject: { @@ -210,3 +207,33 @@ it('VC has correct format (full example)', () => { }, }) }) + +it('reproduces credential in round trip', () => { + const VC = toVc(credential, { + issuer: attestation.owner, + chainGenesisHash: mockedApi.genesisHash, + blockHash, + timestamp, + }) + expect(fromVC(VC)).toMatchObject(credential) +}) + +it('it verifies credential with selected properties revealed', async () => { + const reducedCredential = removeClaimProperties(credential, [ + 'name', + 'birthday', + ]) + const { proof, ...reducedVC } = toVc(reducedCredential, { + issuer: attestation.owner, + chainGenesisHash: mockedApi.genesisHash, + blockHash, + timestamp, + }) + + await expect( + KiltAttestationProofV1.verify(reducedVC, proof, { + api: mockedApi, + cTypes: [cType], + }) + ).resolves.not.toThrow() +}) diff --git a/packages/legacy-credentials/src/vcInterop.ts b/packages/legacy-credentials/src/vcInterop.ts new file mode 100644 index 000000000..a871d4e94 --- /dev/null +++ b/packages/legacy-credentials/src/vcInterop.ts @@ -0,0 +1,180 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +import { + hexToU8a, + stringToU8a, + u8aCmp, + u8aToHex, + u8aToString, +} from '@polkadot/util' +import { base58Decode, base58Encode, blake2AsU8a } from '@polkadot/util-crypto' + +import { CType } from '@kiltprotocol/core' +import { KiltCredentialV1, Types, constants } from '@kiltprotocol/vc-export' + +import type { ICType, IClaim, ICredential } from '@kiltprotocol/types' + +import { makeStatementsJsonLD } from './utils.js' + +/** + * Produces an instance of [[KiltAttestationProofV1]] from an [[ICredential]]. + * + * @param credential Input credential. + * @param opts Additional parameters required for creating a proof from an [[ICredential]]. + * @param opts.blockHash Hash of a block at which the proof must be verifiable. + * @returns An embedded proof for a verifiable credential derived from the input. + */ +function proofFromICredential( + credential: ICredential, + { blockHash }: { blockHash: Uint8Array } +): Types.KiltAttestationProofV1 { + // `block` field is base58 encoding of block hash + const block = base58Encode(blockHash) + // `commitments` (claimHashes) are base58 encoded in new format + const commitments = credential.claimHashes.map((i) => + base58Encode(hexToU8a(i)) + ) + // salt/nonces must be sorted by statement digest (keys) and base58 encoded + const salt = Object.entries(credential.claimNonceMap) + .map(([hsh, slt]) => [hexToU8a(hsh), stringToU8a(slt)]) + .sort((a, b) => u8aCmp(a[0], b[0])) + .map((i) => base58Encode(i[1])) + return { + type: constants.ATTESTATION_PROOF_V1_TYPE, + block, + commitments, + salt, + } +} + +/** + * Transforms an [[ICredential]] object to conform to the KiltCredentialV1 data model. + * + * @param input An [[ICredential]] object. + * @param options Additional required and optional parameters for producing a VC from an [[ICredential]]. + * @param options.issuer The issuer of the attestation to this credential (attester). + * @param options.timestamp Timestamp of the block referenced by blockHash in milliseconds since January 1, 1970, UTC (UNIX epoch). + * @param options.chainGenesisHash Optional: Genesis hash of the chain against which this credential is verifiable. Defaults to the spiritnet genesis hash. + * @returns A KiltCredentialV1 with embedded KiltAttestationProofV1 proof. + */ +function vcFromICredential( + input: ICredential, + { + issuer, + timestamp, + chainGenesisHash, + }: Pick< + Parameters[0], + 'chainGenesisHash' | 'timestamp' | 'issuer' + > +): Omit { + const { + legitimations: legitimationsInput, + delegationId, + rootHash: claimHash, + claim, + } = input + const { cTypeHash, owner: subject, contents: claims } = claim + const cType = CType.hashToId(cTypeHash) + + const legitimations = legitimationsInput.map(({ rootHash: legHash }) => + KiltCredentialV1.idFromRootHash(legHash) + ) + + const vc = KiltCredentialV1.fromInput({ + claimHash, + subject, + claims, + chainGenesisHash, + cType, + issuer, + timestamp, + legitimations, + ...(delegationId && { delegationId }), + }) + + return vc +} + +type Params = Parameters[1] & + Parameters[1] + +/** + * Transforms an [[ICredential]] object to conform to the KiltCredentialV1 data model. + * + * @param input An [[ICredential]] object. + * @param opts Additional required and optional parameters for producing a VC from an [[ICredential]]. + * @param opts.issuer The issuer of the attestation to this credential (attester). + * @param opts.blockHash Hash of any block at which the credential is verifiable (i.e. Attested and not revoked). + * @param opts.timestamp Timestamp of the block referenced by blockHash in milliseconds since January 1, 1970, UTC (UNIX epoch). + * @param opts.chainGenesisHash Optional: Genesis hash of the chain against which this credential is verifiable. Defaults to the spiritnet genesis hash. + * @returns A KiltCredentialV1 with embedded KiltAttestationProofV1 proof. + */ +export function toVc( + input: ICredential, + { blockHash, issuer, chainGenesisHash, timestamp }: Params +): Types.KiltCredentialV1 { + const proof = proofFromICredential(input, { blockHash }) + return { + ...vcFromICredential(input, { issuer, chainGenesisHash, timestamp }), + proof, + } +} + +/** + * Transforms a [[KiltCredentialV1]] object back to the legacy [[ICredential]] data model. + * + * @param input A [[KiltCredentialV1]] object with embedded [[KiltAttestationProofV1]] proof. + * @returns An ICredential. Depending on the input, legitimations may be merely consist of the credential id instead of full ICredentials. + */ +export function fromVC(input: Types.KiltCredentialV1): ICredential { + const { + id: owner, + '@context': { '@vocab': vocab }, + ...contents + } = input.credentialSubject + const cTypeId = vocab.slice(0, -1) as ICType['$id'] + const claim: IClaim = { + owner, + cTypeHash: CType.idToHash(cTypeId), + contents, + } + const { commitments, salt } = input.proof + const claimHashes = commitments.map((c) => u8aToHex(base58Decode(c))) + const hashedStatements = makeStatementsJsonLD(claim) + .map((c) => blake2AsU8a(c)) + .sort(u8aCmp) + const claimNonceMap = hashedStatements.reduce((reduced, hash, idx) => { + return { + ...reduced, + [u8aToHex(hash)]: u8aToString(base58Decode(salt[idx])), + } + }, {}) + const rootHash = u8aToHex(KiltCredentialV1.idToRootHash(input.id)) + const delegationId = u8aToHex(KiltCredentialV1.getDelegationId(input)) + const legitimationVcs = input.federatedTrustModel?.filter( + (i): i is Types.KiltAttesterLegitimationV1 => + i.type === constants.KILT_ATTESTER_LEGITIMATION_V1_TYPE + ) + const legitimations = (legitimationVcs ?? []).map( + ({ id, verifiableCredential }) => + verifiableCredential + ? fromVC(verifiableCredential) + : ({ + rootHash: u8aToHex(KiltCredentialV1.idToRootHash(id)), + } as ICredential) + ) + return { + rootHash, + claim, + claimHashes, + claimNonceMap, + delegationId, + legitimations, + } +} diff --git a/packages/legacy-credentials/tsconfig.build.json b/packages/legacy-credentials/tsconfig.build.json new file mode 100644 index 000000000..ab24dae00 --- /dev/null +++ b/packages/legacy-credentials/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.build.json", + + "compilerOptions": { + "module": "CommonJS", + "outDir": "./lib/cjs" + }, + + "include": [ + "src/**/*.ts", "src/**/*.js" + ], + + "exclude": [ + "coverage", + "**/*.spec.ts", + ] +} diff --git a/packages/legacy-credentials/tsconfig.esm.json b/packages/legacy-credentials/tsconfig.esm.json new file mode 100644 index 000000000..e1f3b73b6 --- /dev/null +++ b/packages/legacy-credentials/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "module": "ES6", + "outDir": "./lib/esm" + } +} diff --git a/packages/vc-export/src/CTypeVerification.spec.ts b/packages/vc-export/src/CTypeVerification.spec.ts index 22530a98d..f512b0250 100644 --- a/packages/vc-export/src/CTypeVerification.spec.ts +++ b/packages/vc-export/src/CTypeVerification.spec.ts @@ -6,33 +6,13 @@ */ import { CType } from '@kiltprotocol/core' -import { randomAsHex, randomAsU8a } from '@polkadot/util-crypto' -import { - attestation, - credential, - cType, -} from './exportToVerifiableCredential.spec' -import { exportICredentialToVc } from './fromICredential' +import { randomAsHex } from '@polkadot/util-crypto' +import { credential as VC, cType } from './__mocks__/testData.js' import { credentialSchema, validateStructure, validateSubject, } from './KiltCredentialV1' -import type { KiltCredentialV1 } from './types' - -let VC: KiltCredentialV1 -const timestamp = 1234567 -const blockHash = randomAsU8a(32) -const attester = attestation.owner - -beforeAll(() => { - VC = exportICredentialToVc(credential, { - issuer: attester, - blockHash, - timestamp, - cType: cType.$id, - }) -}) it('exports to VC including ctype as schema', async () => { expect(VC).toMatchObject({ diff --git a/packages/vc-export/src/KiltAttestationProofV1.spec.ts b/packages/vc-export/src/KiltAttestationProofV1.spec.ts index a82c431e6..0b90d85aa 100644 --- a/packages/vc-export/src/KiltAttestationProofV1.spec.ts +++ b/packages/vc-export/src/KiltAttestationProofV1.spec.ts @@ -8,20 +8,19 @@ import { encodeAddress, randomAsHex, randomAsU8a } from '@polkadot/util-crypto' import { u8aToHex, u8aToU8a } from '@polkadot/util' -import { Credential } from '@kiltprotocol/core' import { parse } from '@kiltprotocol/did' import type { DidUri } from '@kiltprotocol/types' import { attestation, blockHash, - credential, + credential as VC, makeAttestationCreatedEvents, mockedApi, timestamp, cType, -} from './exportToVerifiableCredential.spec' -import { exportICredentialToVc } from './fromICredential' + legacyCredential, +} from './__mocks__/testData.js' import { finalizeProof, initializeProof, @@ -29,9 +28,9 @@ import { verify as verifyOriginal, } from './KiltAttestationProofV1' import { check as checkStatus } from './KiltRevocationStatusV1' -import { fromICredential } from './KiltCredentialV1' import { credentialIdFromRootHash } from './common' import type { KiltCredentialV1 } from './types' +import { fromInput } from './KiltCredentialV1' // the original verify implementation but with a mocked CType loader const verify: typeof verifyOriginal = async (cred, proof, options) => @@ -45,17 +44,7 @@ const verify: typeof verifyOriginal = async (cred, proof, options) => }, }) -let VC: KiltCredentialV1 describe('proofs', () => { - beforeAll(() => { - VC = exportICredentialToVc(credential, { - issuer: attestation.owner, - chainGenesisHash: mockedApi.genesisHash, - blockHash, - timestamp, - }) - }) - it('it verifies proof', async () => { // verify const { proof, ...cred } = VC @@ -73,30 +62,13 @@ describe('proofs', () => { await expect(verify(cred, proof, { api: mockedApi })).resolves.not.toThrow() }) - it('it verifies credential with selected properties revealed', async () => { - const reducedCredential = Credential.removeClaimProperties(credential, [ - 'name', - 'birthday', - ]) - const { proof, ...reducedVC } = exportICredentialToVc(reducedCredential, { - issuer: attestation.owner, - chainGenesisHash: mockedApi.genesisHash, - blockHash, - timestamp, - }) - - await expect( - verify(reducedVC, proof, { api: mockedApi }) - ).resolves.not.toThrow() - }) - it('applies selective disclosure to proof', async () => { const updated = applySelectiveDisclosure(VC, VC.proof, ['name']) - const { contents, owner } = credential.claim + const { name, id } = VC.credentialSubject expect(updated.credential).toHaveProperty('credentialSubject', { '@context': expect.any(Object), - id: owner, - name: contents.name, + id, + name, }) expect(Object.entries(updated.proof.salt)).toHaveLength(2) await expect( @@ -120,7 +92,7 @@ describe('proofs', () => { mockedApi.query.delegation = { delegationNodes: jest.fn(async (nodeId: string | Uint8Array) => { switch (u8aToHex(u8aToU8a(nodeId))) { - case credential.delegationId: + case legacyCredential.delegationId: return mockedApi.createType( 'Option', { @@ -157,7 +129,12 @@ describe('proofs', () => { }) describe('issuance', () => { - const unsigned = fromICredential(credential, { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: subject, '@context': _, ...claims } = VC.credentialSubject + const unsigned = fromInput({ + claims, + subject, + cType: cType.$id, issuer: attestation.owner, timestamp: 0, }) @@ -199,15 +176,6 @@ describe('issuance', () => { }) describe('negative tests', () => { - beforeEach(() => { - VC = exportICredentialToVc(credential, { - issuer: attestation.owner, - chainGenesisHash: mockedApi.genesisHash, - blockHash, - timestamp, - }) - }) - it('errors on proof mismatch', async () => { // @ts-ignore delete VC.proof diff --git a/packages/vc-export/src/KiltAttestationProofV1.ts b/packages/vc-export/src/KiltAttestationProofV1.ts index 55c25ef56..a7ec2359a 100644 --- a/packages/vc-export/src/KiltAttestationProofV1.ts +++ b/packages/vc-export/src/KiltAttestationProofV1.ts @@ -6,7 +6,6 @@ */ import { - hexToU8a, stringToU8a, u8aCmp, u8aConcatStrict, @@ -46,7 +45,6 @@ import type { } from '@kiltprotocol/augment-api' import type { DidUri, - ICredential, ICType, IDelegationNode, KiltAddress, @@ -86,37 +84,6 @@ import type { KiltCredentialV1, } from './types.js' -/** - * Produces an instance of [[KiltAttestationProofV1]] from an [[ICredential]]. - * - * @param credential Input credential. - * @param opts Additional parameters required for creating a proof from an [[ICredential]]. - * @param opts.blockHash Hash of a block at which the proof must be verifiable. - * @returns An embedded proof for a verifiable credential derived from the input. - */ -export function fromICredential( - credential: ICredential, - { blockHash }: { blockHash: Uint8Array } -): KiltAttestationProofV1 { - // `block` field is base58 encoding of block hash - const block = base58Encode(blockHash) - // `commitments` (claimHashes) are base58 encoded in new format - const commitments = credential.claimHashes.map((i) => - base58Encode(hexToU8a(i)) - ) - // salt/nonces must be sorted by statement digest (keys) and base58 encoded - const salt = Object.entries(credential.claimNonceMap) - .map(([hsh, slt]) => [hexToU8a(hsh), stringToU8a(slt)]) - .sort((a, b) => u8aCmp(a[0], b[0])) - .map((i) => base58Encode(i[1])) - return { - type: ATTESTATION_PROOF_V1_TYPE, - block, - commitments, - salt, - } -} - export const proofSchema: JsonSchema.Schema = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', diff --git a/packages/vc-export/src/KiltCredentialV1.ts b/packages/vc-export/src/KiltCredentialV1.ts index 2da955cce..a1c4a0dec 100644 --- a/packages/vc-export/src/KiltCredentialV1.ts +++ b/packages/vc-export/src/KiltCredentialV1.ts @@ -39,6 +39,12 @@ import { jsonLdExpandCredentialSubject, } from './common.js' +export { + credentialIdFromRootHash as idFromRootHash, + credentialIdToRootHash as idToRootHash, + getDelegationNodeIdForCredential as getDelegationId, +} from './common.js' + export const credentialSchema: JsonSchema.Schema = { $id: 'ipfs://QmRpbcBsAPLCKUZSNncPiMxtVfM33UBmudaCMQV9K3FD5z', $schema: 'http://json-schema.org/draft-07/schema#', @@ -286,7 +292,7 @@ export function fromInput({ }, ...(claimHash && { credentialStatus: fromGenesisAndRootHash(chainGenesisHash, claimHash), - id: credentialIdFromRootHash(hexToU8a(claimHash)), + id: credentialIdFromRootHash(claimHash), }), issuer, issuanceDate, @@ -294,55 +300,6 @@ export function fromInput({ } } -/** - * Transforms an [[ICredential]] object to conform to the KiltCredentialV1 data model. - * - * @param input An [[ICredential]] object. - * @param options Additional required and optional parameters for producing a VC from an [[ICredential]]. - * @param options.issuer The issuer of the attestation to this credential (attester). - * @param options.timestamp Timestamp of the block referenced by blockHash in milliseconds since January 1, 1970, UTC (UNIX epoch). - * @param options.cType Optional: The CType object referenced by the [[ICredential]]. - * @param options.chainGenesisHash Optional: Genesis hash of the chain against which this credential is verifiable. Defaults to the spiritnet genesis hash. - * @returns A KiltCredentialV1 with embedded KiltAttestationProofV1 proof. - */ -export function fromICredential( - input: ICredential, - { - issuer, - timestamp, - cType: ctype, - chainGenesisHash = spiritnetGenesisHash, - }: Pick & - Partial> -): Omit { - const { - legitimations: legitimationsInput, - delegationId, - rootHash: claimHash, - claim, - } = input - const { cTypeHash, owner: subject, contents: claims } = claim - const cType = ctype ?? CType.hashToId(cTypeHash) - - const legitimations = legitimationsInput.map(({ rootHash: legHash }) => - credentialIdFromRootHash(hexToU8a(legHash)) - ) - - const vc = fromInput({ - claimHash, - subject, - claims, - chainGenesisHash, - cType, - issuer, - timestamp, - legitimations, - ...(delegationId && { delegationId }), - }) - - return vc -} - export type CTypeLoader = (id: ICType['$id']) => Promise const loadCType: CTypeLoader = async (id) => { diff --git a/packages/vc-export/src/__mocks__/testData.ts b/packages/vc-export/src/__mocks__/testData.ts new file mode 100644 index 000000000..b4d4027ea --- /dev/null +++ b/packages/vc-export/src/__mocks__/testData.ts @@ -0,0 +1,204 @@ +/** + * Copyright (c) 2018-2023, BOTLabs GmbH. + * + * This source code is licensed under the BSD 4-Clause "Original" license + * found in the LICENSE file in the root directory of this source tree. + */ + +import { + hexToU8a, + stringToU8a, + u8aCmp, + u8aConcat, + u8aToU8a, +} from '@polkadot/util' +import { base58Encode, randomAsU8a } from '@polkadot/util-crypto' + +import { Credential } from '@kiltprotocol/core' +import type { IAttestation, ICType, ICredential } from '@kiltprotocol/types' + +import { ApiMocks } from '../../../../tests/testUtils' + +import { fromInput } from '../KiltCredentialV1' +import { ATTESTATION_PROOF_V1_TYPE } from '../constants' +import { KiltCredentialV1 } from '../types' + +export const mockedApi = ApiMocks.createAugmentedApi() + +// index of the attestation pallet, according to the metadata used +const attestationPalletIndex = 62 +// asynchronously check that pallet index is correct +mockedApi.once('ready', () => { + const idx = mockedApi.runtimeMetadata.asLatest.pallets.find((x) => + x.name.match(/attestation/i) + )!.index + if (!idx.eqn(attestationPalletIndex)) { + console.warn( + `The attestation pallet index is expected to be ${attestationPalletIndex}, but the metadata used lists it as ${idx.toNumber()}. This may lead to tests not behaving as expected!` + ) + } +}) +const attestationCreatedIndex = u8aToU8a([ + attestationPalletIndex, + mockedApi.events.attestation.AttestationCreated.meta.index.toNumber(), +]) +export function makeAttestationCreatedEvents(events: unknown[][]) { + return mockedApi.createType( + 'Vec', + events.map((eventData) => ({ + event: u8aConcat( + attestationCreatedIndex, + new (mockedApi.registry.findMetaEvent(attestationCreatedIndex))( + mockedApi.registry, + eventData + ).toU8a() + ), + })) + ) +} + +export const cType: ICType = { + $schema: 'http://kilt-protocol.org/draft-01/ctype#', + title: 'membership', + properties: { + birthday: { + type: 'string', + format: 'date', + }, + name: { + type: 'string', + }, + premium: { + type: 'boolean', + }, + }, + type: 'object', + $id: 'kilt:ctype:0xf0fd09f9ed6233b2627d37eb5d6c528345e8945e0b610e70997ed470728b2ebf', +} + +const _legacyCredential: ICredential = { + claim: { + contents: { + birthday: '1991-01-01', + name: 'Kurt', + premium: true, + }, + owner: 'did:kilt:4r1WkS3t8rbCb11H8t3tJvGVCynwDXSUBiuGB6sLRHzCLCjs', + cTypeHash: + '0xf0fd09f9ed6233b2627d37eb5d6c528345e8945e0b610e70997ed470728b2ebf', + }, + claimHashes: [ + '0x0586412d7b8adf811c288211c9c704b3331bb3adb61fba6448c89453568180f6', + '0x3856178f49d3c379e00793125678eeb8db61cfa4ed32cd7a4b67ac8e27714fc1', + '0x683428497edeba0198f02a45a7015fc2c010fa75994bc1d1372349c25e793a10', + '0x8804cc546c4597b2ab0541dd3a6532e338b0b5b4d2458eb28b4d909a5d4caf4e', + ], + claimNonceMap: { + '0xe5a099ea4f8be89227af8a5d74b0371e1c13232978c8b8edce1ecec698eb2665': + 'eab8a98c-0ef3-4a33-a5c7-c9821b3bec45', + '0x14a06c5955ebc9247c9f54b30e0f1714e6ebd54ae05ad7b16fa9a4643dff1dc2': + 'fda7a7d4-770c-4cae-9cd9-6deebdb3ed80', + '0xb102f462e4cde1b48e7936085cef1e2ab6ae4f7ca46cd3fab06074c00546a33d': + 'ed28443a-ec36-4a54-9caa-6bf014df257d', + '0xf42b46c4a7a3bad68650069bd81fdf2085c9ea02df1c27a82282e97e3f71ef8e': + 'adc7dc71-ab0a-45f9-a091-9f3ec1bb96c7', + }, + legitimations: [], + delegationId: + '0xb102f462e4cde1b48e7936085cef1e2ab6ae4f7ca46cd3fab06074c00546a33d', + rootHash: '0x', +} +_legacyCredential.rootHash = Credential.calculateRootHash(_legacyCredential) + +// eslint-disable-next-line import/no-mutable-exports +export let legacyCredential: ICredential = JSON.parse( + JSON.stringify(_legacyCredential) +) +beforeEach(() => { + legacyCredential = JSON.parse(JSON.stringify(_legacyCredential)) +}) + +export const attestation: IAttestation = { + claimHash: _legacyCredential.rootHash, + cTypeHash: _legacyCredential.claim.cTypeHash, + delegationId: _legacyCredential.delegationId, + owner: 'did:kilt:4sejigvu6STHdYmmYf2SuN92aNp8TbrsnBBDUj7tMrJ9Z3cG', + revoked: false, +} + +export const timestamp = 1234567 +export const blockHash = randomAsU8a(32) +export const genesisHash = randomAsU8a(32) + +const _credential = JSON.stringify({ + ...fromInput({ + claims: _legacyCredential.claim.contents, + claimHash: _legacyCredential.rootHash, + subject: _legacyCredential.claim.owner, + delegationId: _legacyCredential.delegationId ?? undefined, + cType: cType.$id, + issuer: attestation.owner, + chainGenesisHash: genesisHash, + timestamp, + }), + proof: { + type: ATTESTATION_PROOF_V1_TYPE, + // `block` field is base58 encoding of block hash + block: base58Encode(blockHash), + // `commitments` (claimHashes) are base58 encoded in new format + commitments: _legacyCredential.claimHashes.map((i) => + base58Encode(hexToU8a(i)) + ), + // salt/nonces must be sorted by statement digest (keys) and base58 encoded + salt: Object.entries(_legacyCredential.claimNonceMap) + .map(([hsh, slt]) => [hexToU8a(hsh), stringToU8a(slt)]) + .sort((a, b) => u8aCmp(a[0], b[0])) + .map((i) => base58Encode(i[1])), + }, +}) + +// eslint-disable-next-line import/no-mutable-exports +export let credential: KiltCredentialV1 = JSON.parse(_credential) +beforeEach(() => { + credential = JSON.parse(_credential) +}) + +jest.spyOn(mockedApi, 'at').mockImplementation(() => Promise.resolve(mockedApi)) +jest + .spyOn(mockedApi, 'queryMulti') + .mockImplementation((calls) => + Promise.all( + calls.map((call) => (Array.isArray(call) ? call[0](call[1]) : call())) + ) + ) +jest + .spyOn(mockedApi, 'genesisHash', 'get') + .mockImplementation(() => genesisHash as any) +mockedApi.query.attestation = { + attestations: jest.fn().mockResolvedValue( + mockedApi.createType('Option', { + ctypeHash: attestation.cTypeHash, + attester: '4sejigvu6STHdYmmYf2SuN92aNp8TbrsnBBDUj7tMrJ9Z3cG', + revoked: false, + authorizationId: { Delegation: attestation.delegationId }, + }) + ), +} as any +mockedApi.query.timestamp = { + now: jest.fn().mockResolvedValue(mockedApi.createType('u64', timestamp)), +} as any + +mockedApi.query.system = { + events: jest + .fn() + .mockResolvedValue( + makeAttestationCreatedEvents([ + [ + '4sejigvu6STHdYmmYf2SuN92aNp8TbrsnBBDUj7tMrJ9Z3cG', + attestation.claimHash, + attestation.cTypeHash, + { Delegation: attestation.delegationId }, + ], + ]) + ), +} as any diff --git a/packages/vc-export/src/common.ts b/packages/vc-export/src/common.ts index 5d8751a32..edee7153d 100644 --- a/packages/vc-export/src/common.ts +++ b/packages/vc-export/src/common.ts @@ -5,11 +5,13 @@ * found in the LICENSE file in the root directory of this source tree. */ -import { base58Decode, base58Encode } from '@polkadot/util-crypto' import type { ApiPromise } from '@polkadot/api' +import { base58Decode, base58Encode } from '@polkadot/util-crypto' +import { hexToU8a } from '@polkadot/util' -import { Caip19, Caip2 } from './CAIP/index.js' +import type { HexString } from '@kiltprotocol/types' +import { Caip19, Caip2 } from './CAIP/index.js' import { KILT_ATTESTER_DELEGATION_V1_TYPE, KILT_CREDENTIAL_IRI_PREFIX, @@ -139,11 +141,12 @@ export function credentialIdToRootHash( /** * Transforms the credential root hash to an IRI that functions as the VC's id. * - * @param rootHash Credential root hash as a Uint8Array. + * @param rootHash Credential root hash as a Uint8Array or HexString. * @returns An IRI composed by prefixing the root hash with the [[KILT_CREDENTIAL_IRI_PREFIX]]. */ export function credentialIdFromRootHash( - rootHash: Uint8Array + rootHash: Uint8Array | HexString ): KiltCredentialV1['id'] { - return `${KILT_CREDENTIAL_IRI_PREFIX}${base58Encode(rootHash, false)}` + const bytes = typeof rootHash === 'string' ? hexToU8a(rootHash) : rootHash + return `${KILT_CREDENTIAL_IRI_PREFIX}${base58Encode(bytes, false)}` } diff --git a/packages/vc-export/src/fromICredential.ts b/packages/vc-export/src/fromICredential.ts deleted file mode 100644 index 191c10c99..000000000 --- a/packages/vc-export/src/fromICredential.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright (c) 2018-2023, BOTLabs GmbH. - * - * This source code is licensed under the BSD 4-Clause "Original" license - * found in the LICENSE file in the root directory of this source tree. - */ - -import type { ICredential } from '@kiltprotocol/types' - -import { fromICredential as vcFromCredential } from './KiltCredentialV1.js' -import { fromICredential as proofFromCredential } from './KiltAttestationProofV1.js' -import type { KiltCredentialV1 } from './types.js' - -type Params = Parameters[1] & - Parameters[1] - -/** - * Transforms an [[ICredential]] object to conform to the KiltCredentialV1 data model. - * - * @param input An [[ICredential]] object. - * @param opts Additional required and optional parameters for producing a VC from an [[ICredential]]. - * @param opts.issuer The issuer of the attestation to this credential (attester). - * @param opts.blockHash Hash of any block at which the credential is verifiable (i.e. Attested and not revoked). - * @param opts.timestamp Timestamp of the block referenced by blockHash in milliseconds since January 1, 1970, UTC (UNIX epoch). - * @param opts.chainGenesisHash Optional: Genesis hash of the chain against which this credential is verifiable. Defaults to the spiritnet genesis hash. - * @param opts.cType Optional: The CType object referenced by the [[ICredential]]. - * @returns A KiltCredentialV1 with embedded KiltAttestationProofV1 proof. - */ -export function exportICredentialToVc( - input: ICredential, - { blockHash, issuer, chainGenesisHash, timestamp, cType }: Params -): KiltCredentialV1 { - const proof = proofFromCredential(input, { blockHash }) - return { - ...vcFromCredential(input, { issuer, chainGenesisHash, timestamp, cType }), - proof, - } -} diff --git a/packages/vc-export/src/index.ts b/packages/vc-export/src/index.ts index 0fbdf8617..087d7491b 100644 --- a/packages/vc-export/src/index.ts +++ b/packages/vc-export/src/index.ts @@ -9,13 +9,12 @@ * @module @kiltprotocol/vc-export */ -export * from './fromICredential.js' export * as KiltCredentialV1 from './KiltCredentialV1.js' export * as KiltAttestationProofV1 from './KiltAttestationProofV1.js' export * as KiltRevocationStatusV1 from './KiltRevocationStatusV1.js' export * as Presentation from './Presentation.js' export * as DidJWT from './DidJwt.js' export * as vcjs from './vc-js/index.js' -export * from './types.js' +export * as Types from './types.js' export * as constants from './constants.js' export * from './errors.js' diff --git a/packages/vc-export/src/vc-js/examples/ICredentialExample.json b/packages/vc-export/src/vc-js/examples/ICredentialExample.json deleted file mode 100644 index 09cbcda5d..000000000 --- a/packages/vc-export/src/vc-js/examples/ICredentialExample.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "claim": { - "cTypeHash": "0x3291bb126e33b4862d421bfaa1d2f272e6cdfc4f96658988fbcffea8914bd9ac", - "contents": { - "Email": "ingo@kilt.io" - }, - "owner": "did:kilt:4sJm5Zsvdi32hU88xbL3v6VQ877P4HLaWVYUXgcSyQR8URTu" - }, - "claimHashes": [ - "0x8113c20adf617adb9fe3a2c61cc2614bf02cd58e0e42cb31356e7f5c052e65de", - "0xa19685266e47579ecd72c30b31a928eef0bd71b7d297511c8bef952f2a5822a1" - ], - "claimNonceMap": { - "0x02eaa62e144281c9f73355cdb5e1f4edf27adc4e0510c2e60dca793c794dba6a": "e8f78c9e-70b5-48ea-990f-97782bc62c84", - "0x1767f2220a9b07e22b73c5b36fa90e6f14338b6198e7696daf464914942734ab": "1f454fcc-dc73-46d4-9478-db5e4c8dda3b" - }, - "legitimations": [], - "delegationId": null, - "rootHash": "0x4fb274ed275ae1c3a719428088ffde0bbc10e456eba8aedc9687178a4ce47c20", - "claimerSignature": { - "keyId": "did:kilt:4sJm5Zsvdi32hU88xbL3v6VQ877P4HLaWVYUXgcSyQR8URTu#0xad991c68c9f1c6c4f869fa19a217db30aff0f74963ca7e26206f7102b229df5b", - "signature": "0xfa71e745c21d7b4ec6f8d54ac5b2fea9bacf91ffb8f56b359a3e5af0119957030a28944011690d404c59ea814c5324298db0ef5b3332868bbdcf33b25bb9f388" - } -} \ No newline at end of file diff --git a/packages/vc-export/src/vc-js/examples/KiltCredentialV1.json b/packages/vc-export/src/vc-js/examples/KiltCredentialV1.json new file mode 100644 index 000000000..b92ca33cd --- /dev/null +++ b/packages/vc-export/src/vc-js/examples/KiltCredentialV1.json @@ -0,0 +1,42 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.kilt.io/contexts/credentials" + ], + "type": [ + "VerifiableCredential", + "KiltCredentialV1", + "kilt:ctype:0x3291bb126e33b4862d421bfaa1d2f272e6cdfc4f96658988fbcffea8914bd9ac" + ], + "nonTransferable": true, + "credentialSubject": { + "@context": { + "@vocab": "kilt:ctype:0x3291bb126e33b4862d421bfaa1d2f272e6cdfc4f96658988fbcffea8914bd9ac#" + }, + "id": "did:kilt:4sJm5Zsvdi32hU88xbL3v6VQ877P4HLaWVYUXgcSyQR8URTu", + "Email": "ingo@kilt.io" + }, + "credentialSchema": { + "id": "ipfs://QmRpbcBsAPLCKUZSNncPiMxtVfM33UBmudaCMQV9K3FD5z", + "type": "JsonSchema2023" + }, + "credentialStatus": { + "id": "polkadot:411f057b9107718c9624d6aa4a3f23c1/kilt:attestation/6N736gaJzLkwZXAgg51eZFjocLHGp2RH3YPpYnvqDHzw", + "type": "KiltRevocationStatusV1" + }, + "id": "kilt:credential:6N736gaJzLkwZXAgg51eZFjocLHGp2RH3YPpYnvqDHzw", + "issuer": "did:kilt:4pnfkRn5UurBJTW92d9TaVLR2CqJdY4z5HPjrEbpGyBykare", + "issuanceDate": "2022-04-11T09:41:00.000Z", + "proof": { + "type": "KiltAttestationProofV1", + "block": "AwpqjHSLKHB6gtKrg5zbNi8MmZD2aYFNUzn5tPNtHhgy", + "commitments": [ + "9gs4tcfepmrPL8s1i3mysqgTCyXABWQAYwzMVd3hGdjs", + "Bsmm5xbBnQRVKJKTtD9qAzGyUaaqNoRWhk4tgNkJrWn4" + ], + "salt": [ + "maY9L8qkvrVGySJyWTuWnZALvDRkvjj47NorFpjXNTUBrX2ZH", + "NkqZ868gmhoYDvt1X5hsHhHDP94indXqErLcVBGZRBiMgHxE5" + ] + } + } \ No newline at end of file diff --git a/packages/vc-export/src/vc-js/suites/KiltAttestationProofV1.spec.ts b/packages/vc-export/src/vc-js/suites/KiltAttestationProofV1.spec.ts index 03272e689..1e11bdc8f 100644 --- a/packages/vc-export/src/vc-js/suites/KiltAttestationProofV1.spec.ts +++ b/packages/vc-export/src/vc-js/suites/KiltAttestationProofV1.spec.ts @@ -5,65 +5,67 @@ * found in the LICENSE file in the root directory of this source tree. */ -import { hexToU8a, u8aEq } from '@polkadot/util' -// @ts-expect-error not a typescript module -import * as vcjs from '@digitalbazaar/vc' +import { u8aEq } from '@polkadot/util' +import { base58Decode } from '@polkadot/util-crypto' import { Ed25519Signature2020, suiteContext as Ed25519Signature2020Context, // @ts-expect-error not a typescript module } from '@digitalbazaar/ed25519-signature-2020' // @ts-expect-error not a typescript module +import * as vcjs from '@digitalbazaar/vc' +// @ts-expect-error not a typescript module import jsigs from 'jsonld-signatures' // cjs module // @ts-expect-error not a typescript module import jsonld from 'jsonld' // cjs module -import { Credential } from '@kiltprotocol/core' import { ConfigService } from '@kiltprotocol/config' import * as Did from '@kiltprotocol/did' -import { Crypto } from '@kiltprotocol/utils' import type { ConformingDidDocument, + HexString, ICType, - IClaim, - ICredential, + KiltAddress, KiltKeyringPair, SubmittableExtrinsic, } from '@kiltprotocol/types' +import { Crypto } from '@kiltprotocol/utils' -import { exportICredentialToVc } from '../../fromICredential.js' import { DidSigner, TxHandler, applySelectiveDisclosure, + finalizeProof, + initializeProof, } from '../../KiltAttestationProofV1.js' -import { KiltAttestationProofV1Purpose } from '../purposes/KiltAttestationProofV1Purpose.js' -import { - JsonLdObj, - combineDocumentLoaders, - kiltContextsLoader, - kiltDidLoader, -} from '../documentLoader.js' +import { idToRootHash } from '../../KiltCredentialV1.js' import { KILT_CREDENTIAL_CONTEXT_URL, W3C_CREDENTIAL_CONTEXT_URL, } from '../../constants.js' -import { Sr25519Signature2020 } from './Sr25519Signature2020.js' -import { - CredentialStub, - KiltAttestationV1Suite, -} from './KiltAttestationProofV1.js' -import ingosCredential from '../examples/ICredentialExample.json' import { cType, makeAttestationCreatedEvents, mockedApi, -} from '../../exportToVerifiableCredential.spec.js' +} from '../../__mocks__/testData.js' import type { KiltAttestationProofV1, - Proof, KiltCredentialV1, + Proof, } from '../../types.js' +import { + JsonLdObj, + combineDocumentLoaders, + kiltContextsLoader, + kiltDidLoader, +} from '../documentLoader.js' +import ingosCredential from '../examples/KiltCredentialV1.json' +import { KiltAttestationProofV1Purpose } from '../purposes/KiltAttestationProofV1Purpose.js' +import { + CredentialStub, + KiltAttestationV1Suite, +} from './KiltAttestationProofV1.js' +import { Sr25519Signature2020 } from './Sr25519Signature2020.js' import { makeFakeDid } from './Sr25519Signature2020.spec' jest.mock('@kiltprotocol/did', () => ({ @@ -75,55 +77,48 @@ jest.mock('@kiltprotocol/did', () => ({ // is not needed and imports a dependency that does not work in node 18 jest.mock('@digitalbazaar/http-client', () => ({})) -const attester = '4pnfkRn5UurBJTW92d9TaVLR2CqJdY4z5HPjrEbpGyBykare' -const timestamp = 1_649_670_060_000 -const blockHash = hexToU8a( - '0x93c4a399abff5a68812479445d121995fde278b7a29d5863259cf7b6b6f1dc7e' -) +const attester = ingosCredential.issuer.split(':')[2] as KiltAddress +const timestamp = new Date(ingosCredential.issuanceDate).getTime() +const blockHash = base58Decode(ingosCredential.proof.block) const { genesisHash } = mockedApi +const ctypeHash = ingosCredential.type[2].split(':')[2] as HexString -const attestedVc = exportICredentialToVc(ingosCredential as ICredential, { - issuer: `did:kilt:${attester}`, - chainGenesisHash: genesisHash, - blockHash, - timestamp, -}) +const attestedVc = finalizeProof( + { ...ingosCredential } as unknown as KiltCredentialV1, + ingosCredential.proof as KiltAttestationProofV1, + { blockHash, timestamp, genesisHash } +) -const notAttestedVc = exportICredentialToVc( - Credential.fromClaim(ingosCredential.claim as IClaim), - { - issuer: `did:kilt:${attester}`, - chainGenesisHash: genesisHash, - blockHash, - timestamp, - } +const notAttestedVc = finalizeProof( + attestedVc, + initializeProof(attestedVc)[0], + { blockHash, timestamp, genesisHash } ) -const revokedCredential = Credential.fromClaim(ingosCredential.claim as IClaim) -const revokedVc = exportICredentialToVc(revokedCredential, { - issuer: `did:kilt:${attester}`, - chainGenesisHash: genesisHash, + +const revokedVc = finalizeProof(attestedVc, initializeProof(attestedVc)[0], { blockHash, timestamp, + genesisHash, }) jest.mocked(mockedApi.query.attestation.attestations).mockImplementation( // @ts-expect-error async (claimHash) => { - if (u8aEq(claimHash, ingosCredential.rootHash)) { + if (u8aEq(claimHash, idToRootHash(attestedVc.id))) { return mockedApi.createType( 'Option', { - ctypeHash: ingosCredential.claim.cTypeHash, + ctypeHash, attester, revoked: false, } ) } - if (u8aEq(claimHash, revokedCredential.rootHash)) { + if (u8aEq(claimHash, idToRootHash(revokedVc.id))) { return mockedApi.createType( 'Option', { - ctypeHash: revokedCredential.claim.cTypeHash, + ctypeHash, attester, revoked: true, } @@ -136,13 +131,8 @@ jest.mocked(mockedApi.query.attestation.attestations).mockImplementation( ) jest.mocked(mockedApi.query.system.events).mockResolvedValue( makeAttestationCreatedEvents([ - [attester, ingosCredential.rootHash, ingosCredential.claim.cTypeHash, null], - [ - attester, - revokedCredential.rootHash, - revokedCredential.claim.cTypeHash, - null, - ], + [attester, idToRootHash(attestedVc.id), ctypeHash, null], + [attester, idToRootHash(revokedVc.id), ctypeHash, null], ]) as any ) jest diff --git a/packages/vc-export/src/vc-js/suites/Sr25519Signature2020.spec.ts b/packages/vc-export/src/vc-js/suites/Sr25519Signature2020.spec.ts index b7b94f442..525a152ab 100644 --- a/packages/vc-export/src/vc-js/suites/Sr25519Signature2020.spec.ts +++ b/packages/vc-export/src/vc-js/suites/Sr25519Signature2020.spec.ts @@ -25,7 +25,7 @@ import { import { W3C_CREDENTIAL_CONTEXT_URL } from '../../constants.js' import { Sr25519Signature2020 } from './Sr25519Signature2020.js' import { Sr25519VerificationKey2020 } from './Sr25519VerificationKey.js' -import ingosCredential from '../examples/ICredentialExample.json' +import ingosCredential from '../examples/KiltCredentialV1.json' import type { VerifiableCredential } from '../../types.js' // is not needed and imports a dependency that does not work in node 18 @@ -47,7 +47,7 @@ export async function makeFakeDid() { const keypair = Crypto.makeKeypairFromUri('//Ingo', 'sr25519') const didDocument = Did.exportToDidDocument( { - uri: ingosCredential.claim.owner as DidUri, + uri: ingosCredential.credentialSubject.id as DidUri, authentication: [ { ...keypair, @@ -101,14 +101,8 @@ it('issues and verifies a signed credential', async () => { const credential = { '@context': [W3C_CREDENTIAL_CONTEXT_URL] as any, type: ['VerifiableCredential'], - credentialSubject: { - '@context': { - '@vocab': `kilt:ctype:${ingosCredential.claim.cTypeHash}#`, - }, - id: ingosCredential.claim.owner, - ...ingosCredential.claim.contents, - }, - issuer: ingosCredential.claim.owner, + credentialSubject: ingosCredential.credentialSubject, + issuer: ingosCredential.credentialSubject.id, } as Partial const verifiableCredential = await vcjs.issue({ diff --git a/packages/vc-export/tsconfig.build.json b/packages/vc-export/tsconfig.build.json index d59aa31ce..9776d9a24 100644 --- a/packages/vc-export/tsconfig.build.json +++ b/packages/vc-export/tsconfig.build.json @@ -13,5 +13,6 @@ "exclude": [ "coverage", "**/*.spec.ts", + "**/__mocks__" ] } diff --git a/tsconfig.docs.json b/tsconfig.docs.json index 6dd82a73b..8cc7d23de 100644 --- a/tsconfig.docs.json +++ b/tsconfig.docs.json @@ -16,6 +16,7 @@ "packages/messaging/src/index.ts", "packages/vc-export/src/index.ts", "packages/sdk-js/src/index.ts", + "packages/legacy-credentials/src/index.ts" ], "out": "docs/api", "theme": "default", diff --git a/tsconfig.json b/tsconfig.json index 186eb2f1b..de4594ec8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "@kiltprotocol/augment-api": ["augment-api/src"], "@kiltprotocol/augment-api/extraDefs": ["augment-api/src/interfaces/extraDefs"], "@kiltprotocol/type-definitions": ["type-definitions/src"], + "@kiltprotocol/legacy-credentials": ["legacy-credentials/src"] } }, } diff --git a/yarn.lock b/yarn.lock index 59a8cee15..5a7b214d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2028,6 +2028,23 @@ __metadata: languageName: unknown linkType: soft +"@kiltprotocol/legacy-credentials@workspace:packages/legacy-credentials": + version: 0.0.0-use.local + resolution: "@kiltprotocol/legacy-credentials@workspace:packages/legacy-credentials" + dependencies: + "@kiltprotocol/config": "workspace:*" + "@kiltprotocol/core": "workspace:*" + "@kiltprotocol/did": "workspace:*" + "@kiltprotocol/types": "workspace:*" + "@kiltprotocol/utils": "workspace:*" + "@kiltprotocol/vc-export": "workspace:*" + "@polkadot/util": ^12.0.0 + "@polkadot/util-crypto": ^12.0.0 + rimraf: ^3.0.2 + typescript: ^4.8.3 + languageName: unknown + linkType: soft + "@kiltprotocol/sdk-js@workspace:packages/sdk-js": version: 0.0.0-use.local resolution: "@kiltprotocol/sdk-js@workspace:packages/sdk-js" @@ -2086,7 +2103,7 @@ __metadata: languageName: unknown linkType: soft -"@kiltprotocol/vc-export@workspace:packages/vc-export": +"@kiltprotocol/vc-export@workspace:*, @kiltprotocol/vc-export@workspace:packages/vc-export": version: 0.0.0-use.local resolution: "@kiltprotocol/vc-export@workspace:packages/vc-export" dependencies: