diff --git a/packages/askar/README.md b/packages/askar/README.md new file mode 100644 index 0000000000..5f68099a30 --- /dev/null +++ b/packages/askar/README.md @@ -0,0 +1,31 @@ +

+
+ Hyperledger Aries logo +

+

Aries Framework JavaScript Askar Module

+

+ License + typescript + @aries-framework/askar version + +

+
+ +Askar module for [Aries Framework JavaScript](https://github.com/hyperledger/aries-framework-javascript.git). diff --git a/packages/askar/jest.config.ts b/packages/askar/jest.config.ts new file mode 100644 index 0000000000..55c67d70a6 --- /dev/null +++ b/packages/askar/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from '@jest/types' + +import base from '../../jest.config.base' + +import packageJson from './package.json' + +const config: Config.InitialOptions = { + ...base, + name: packageJson.name, + displayName: packageJson.name, + setupFilesAfterEnv: ['./tests/setup.ts'], +} + +export default config diff --git a/packages/askar/package.json b/packages/askar/package.json new file mode 100644 index 0000000000..5ed1b8b150 --- /dev/null +++ b/packages/askar/package.json @@ -0,0 +1,41 @@ +{ + "name": "@aries-framework/askar", + "main": "build/index", + "types": "build/index", + "version": "0.3.3", + "private": true, + "files": [ + "build" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/hyperledger/aries-framework-javascript/tree/main/packages/askar", + "repository": { + "type": "git", + "url": "https://github.com/hyperledger/aries-framework-javascript", + "directory": "packages/askar" + }, + "scripts": { + "build": "yarn run clean && yarn run compile", + "clean": "rimraf ./build", + "compile": "tsc -p tsconfig.build.json", + "prepublishOnly": "yarn run build", + "test": "jest" + }, + "dependencies": { + "@aries-framework/core": "0.3.3", + "@hyperledger/aries-askar-shared": "^0.1.0-dev.1", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "rxjs": "^7.2.0", + "tsyringe": "^4.7.0" + }, + "devDependencies": { + "@hyperledger/aries-askar-nodejs": "^0.1.0-dev.1", + "reflect-metadata": "^0.1.13", + "rimraf": "^4.0.7", + "typescript": "~4.9.4" + } +} diff --git a/packages/askar/src/AskarModule.ts b/packages/askar/src/AskarModule.ts new file mode 100644 index 0000000000..5eccb13b3d --- /dev/null +++ b/packages/askar/src/AskarModule.ts @@ -0,0 +1,33 @@ +import type { DependencyManager, Module } from '@aries-framework/core' + +import { AriesFrameworkError, InjectionSymbols } from '@aries-framework/core' + +import { AskarStorageService } from './storage' +import { AskarWallet } from './wallet' + +export class AskarModule implements Module { + public register(dependencyManager: DependencyManager) { + try { + // eslint-disable-next-line import/no-extraneous-dependencies + require('@hyperledger/aries-askar-nodejs') + } catch (error) { + try { + require('@hyperledger/aries-askar-react-native') + } catch (error) { + throw new Error('Could not load aries-askar bindings') + } + } + + if (dependencyManager.isRegistered(InjectionSymbols.Wallet)) { + throw new AriesFrameworkError('There is an instance of Wallet already registered') + } else { + dependencyManager.registerContextScoped(InjectionSymbols.Wallet, AskarWallet) + } + + if (dependencyManager.isRegistered(InjectionSymbols.StorageService)) { + throw new AriesFrameworkError('There is an instance of StorageService already registered') + } else { + dependencyManager.registerSingleton(InjectionSymbols.StorageService, AskarStorageService) + } + } +} diff --git a/packages/askar/src/AskarModuleConfig.ts b/packages/askar/src/AskarModuleConfig.ts new file mode 100644 index 0000000000..c2104eff8e --- /dev/null +++ b/packages/askar/src/AskarModuleConfig.ts @@ -0,0 +1,44 @@ +import type { AriesAskar } from './types' + +/** + * AskarModuleConfigOptions defines the interface for the options of the AskarModuleConfig class. + */ +export interface AskarModuleConfigOptions { + /** + * Implementation of the Askar interface according to aries-askar JavaScript wrapper. + * + * + * ## Node.JS + * + * ```ts + * import { NodeJSAriesAskar } from 'aries-askar-nodejs' + * + * const askarModule = new AskarModule({ + * askar: new NodeJSAriesAskar() + * }) + * ``` + * + * ## React Native + * + * ```ts + * import { ReactNativeAriesAskar } from 'aries-askar-react-native' + * + * const askarModule = new AskarModule({ + * askar: new ReactNativeAriesAskar() + * }) + * ``` + */ + askar: AriesAskar +} + +export class AskarModuleConfig { + private options: AskarModuleConfigOptions + + public constructor(options: AskarModuleConfigOptions) { + this.options = options + } + + public get askar() { + return this.options.askar + } +} diff --git a/packages/askar/src/index.ts b/packages/askar/src/index.ts new file mode 100644 index 0000000000..d7afa60eab --- /dev/null +++ b/packages/askar/src/index.ts @@ -0,0 +1,9 @@ +// Wallet +export { AskarWallet } from './wallet' + +// Storage +export { AskarStorageService } from './storage' + +// Module +export { AskarModule } from './AskarModule' +export { AskarModuleConfig } from './AskarModuleConfig' diff --git a/packages/askar/src/storage/AskarStorageService.ts b/packages/askar/src/storage/AskarStorageService.ts new file mode 100644 index 0000000000..e7c96399c2 --- /dev/null +++ b/packages/askar/src/storage/AskarStorageService.ts @@ -0,0 +1,177 @@ +import type { BaseRecordConstructor, AgentContext, BaseRecord, Query, StorageService } from '@aries-framework/core' + +import { + RecordDuplicateError, + WalletError, + RecordNotFoundError, + injectable, + JsonTransformer, +} from '@aries-framework/core' +import { Scan } from '@hyperledger/aries-askar-shared' + +import { askarErrors, isAskarError } from '../utils/askarError' +import { assertAskarWallet } from '../utils/assertAskarWallet' + +import { askarQueryFromSearchQuery, recordToInstance, transformFromRecordTagValues } from './utils' + +@injectable() +export class AskarStorageService implements StorageService { + /** @inheritDoc */ + public async save(agentContext: AgentContext, record: T) { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + const value = JsonTransformer.serialize(record) + const tags = transformFromRecordTagValues(record.getTags()) as Record + + try { + await session.insert({ category: record.type, name: record.id, value, tags }) + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.Duplicate) { + throw new RecordDuplicateError(`Record with id ${record.id} already exists`, { recordType: record.type }) + } + + throw new WalletError('Error saving record', { cause: error }) + } + } + + /** @inheritDoc */ + public async update(agentContext: AgentContext, record: T): Promise { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + const value = JsonTransformer.serialize(record) + const tags = transformFromRecordTagValues(record.getTags()) as Record + + try { + await session.replace({ category: record.type, name: record.id, value, tags }) + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.NotFound) { + throw new RecordNotFoundError(`record with id ${record.id} not found.`, { + recordType: record.type, + cause: error, + }) + } + + throw new WalletError('Error updating record', { cause: error }) + } + } + + /** @inheritDoc */ + public async delete(agentContext: AgentContext, record: T) { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + try { + await session.remove({ category: record.type, name: record.id }) + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.NotFound) { + throw new RecordNotFoundError(`record with id ${record.id} not found.`, { + recordType: record.type, + cause: error, + }) + } + throw new WalletError('Error deleting record', { cause: error }) + } + } + + /** @inheritDoc */ + public async deleteById( + agentContext: AgentContext, + recordClass: BaseRecordConstructor, + id: string + ): Promise { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + try { + await session.remove({ category: recordClass.type, name: id }) + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.NotFound) { + throw new RecordNotFoundError(`record with id ${id} not found.`, { + recordType: recordClass.type, + cause: error, + }) + } + throw new WalletError('Error deleting record', { cause: error }) + } + } + + /** @inheritDoc */ + public async getById(agentContext: AgentContext, recordClass: BaseRecordConstructor, id: string): Promise { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + try { + const record = await session.fetch({ category: recordClass.type, name: id }) + if (!record) { + throw new RecordNotFoundError(`record with id ${id} not found.`, { + recordType: recordClass.type, + }) + } + return recordToInstance(record, recordClass) + } catch (error) { + if ( + isAskarError(error) && + (error.code === askarErrors.NotFound || + // FIXME: this is current output from askar wrapper but does not describe specifically a not found scenario + error.message === 'Received null pointer. The native library could not find the value.') + ) { + throw new RecordNotFoundError(`record with id ${id} not found.`, { + recordType: recordClass.type, + cause: error, + }) + } + throw new WalletError(`Error getting record`, { cause: error }) + } + } + + /** @inheritDoc */ + public async getAll(agentContext: AgentContext, recordClass: BaseRecordConstructor): Promise { + assertAskarWallet(agentContext.wallet) + const session = agentContext.wallet.session + + const records = await session.fetchAll({ category: recordClass.type }) + + const instances = [] + for (const record of records) { + instances.push(recordToInstance(record, recordClass)) + } + return instances + } + + /** @inheritDoc */ + public async findByQuery( + agentContext: AgentContext, + recordClass: BaseRecordConstructor, + query: Query + ): Promise { + assertAskarWallet(agentContext.wallet) + const store = agentContext.wallet.store + + const askarQuery = askarQueryFromSearchQuery(query) + + const scan = new Scan({ + category: recordClass.type, + store, + tagFilter: askarQuery, + }) + + const instances = [] + try { + const records = await scan.fetchAll() + for (const record of records) { + instances.push(recordToInstance(record, recordClass)) + } + return instances + } catch (error) { + if ( + isAskarError(error) && // FIXME: this is current output from askar wrapper but does not describe specifically a 0 length scenario + error.message === 'Received null pointer. The native library could not find the value.' + ) { + return instances + } + throw new WalletError(`Error executing query`, { cause: error }) + } + } +} diff --git a/packages/askar/src/storage/__tests__/AskarStorageService.test.ts b/packages/askar/src/storage/__tests__/AskarStorageService.test.ts new file mode 100644 index 0000000000..1ba1bf329f --- /dev/null +++ b/packages/askar/src/storage/__tests__/AskarStorageService.test.ts @@ -0,0 +1,307 @@ +import type { AgentContext, TagsBase } from '@aries-framework/core' + +import { + TypedArrayEncoder, + SigningProviderRegistry, + RecordDuplicateError, + RecordNotFoundError, +} from '@aries-framework/core' +import { ariesAskar } from '@hyperledger/aries-askar-shared' + +import { TestRecord } from '../../../../core/src/storage/__tests__/TestRecord' +import { agentDependencies, getAgentConfig, getAgentContext } from '../../../../core/tests/helpers' +import { AskarWallet } from '../../wallet/AskarWallet' +import { AskarStorageService } from '../AskarStorageService' +import { askarQueryFromSearchQuery } from '../utils' + +describe('AskarStorageService', () => { + let wallet: AskarWallet + let storageService: AskarStorageService + let agentContext: AgentContext + + beforeEach(async () => { + const agentConfig = getAgentConfig('AskarStorageServiceTest') + + wallet = new AskarWallet(agentConfig.logger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + agentContext = getAgentContext({ + wallet, + agentConfig, + }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await wallet.createAndOpen(agentConfig.walletConfig!) + storageService = new AskarStorageService() + }) + + afterEach(async () => { + await wallet.delete() + }) + + const insertRecord = async ({ id, tags }: { id?: string; tags?: TagsBase }) => { + const props = { + id, + foo: 'bar', + tags: tags ?? { myTag: 'foobar' }, + } + const record = new TestRecord(props) + await storageService.save(agentContext, record) + return record + } + + describe('tag transformation', () => { + it('should correctly transform tag values to string before storing', async () => { + const record = await insertRecord({ + id: 'test-id', + tags: { + someBoolean: true, + someOtherBoolean: false, + someStringValue: 'string', + anArrayValue: ['foo', 'bar'], + // booleans are stored as '1' and '0' so we store the string values '1' and '0' as 'n__1' and 'n__0' + someStringNumberValue: '1', + anotherStringNumberValue: '0', + }, + }) + + const retrieveRecord = await ariesAskar.sessionFetch({ + category: record.type, + name: record.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sessionHandle: wallet.session.handle!, + forUpdate: false, + }) + + expect(JSON.parse(retrieveRecord.getTags(0))).toEqual({ + someBoolean: '1', + someOtherBoolean: '0', + someStringValue: 'string', + 'anArrayValue:foo': '1', + 'anArrayValue:bar': '1', + someStringNumberValue: 'n__1', + anotherStringNumberValue: 'n__0', + }) + }) + + it('should correctly transform tag values from string after retrieving', async () => { + await ariesAskar.sessionUpdate({ + category: TestRecord.type, + name: 'some-id', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sessionHandle: wallet.session.handle!, + value: TypedArrayEncoder.fromString('{}'), + tags: { + someBoolean: '1', + someOtherBoolean: '0', + someStringValue: 'string', + 'anArrayValue:foo': '1', + 'anArrayValue:bar': '1', + // booleans are stored as '1' and '0' so we store the string values '1' and '0' as 'n__1' and 'n__0' + someStringNumberValue: 'n__1', + anotherStringNumberValue: 'n__0', + }, + operation: 0, // EntryOperation.Insert + }) + + const record = await storageService.getById(agentContext, TestRecord, 'some-id') + + expect(record.getTags()).toEqual({ + someBoolean: true, + someOtherBoolean: false, + someStringValue: 'string', + anArrayValue: expect.arrayContaining(['bar', 'foo']), + someStringNumberValue: '1', + anotherStringNumberValue: '0', + }) + }) + }) + + describe('save()', () => { + it('should throw RecordDuplicateError if a record with the id already exists', async () => { + const record = await insertRecord({ id: 'test-id' }) + + return expect(() => storageService.save(agentContext, record)).rejects.toThrowError(RecordDuplicateError) + }) + + it('should save the record', async () => { + const record = await insertRecord({ id: 'test-id' }) + const found = await storageService.getById(agentContext, TestRecord, 'test-id') + + expect(record).toEqual(found) + }) + }) + + describe('getById()', () => { + it('should throw RecordNotFoundError if the record does not exist', async () => { + return expect(() => storageService.getById(agentContext, TestRecord, 'does-not-exist')).rejects.toThrowError( + RecordNotFoundError + ) + }) + + it('should return the record by id', async () => { + const record = await insertRecord({ id: 'test-id' }) + const found = await storageService.getById(agentContext, TestRecord, 'test-id') + + expect(found).toEqual(record) + }) + }) + + describe('update()', () => { + it('should throw RecordNotFoundError if the record does not exist', async () => { + const record = new TestRecord({ + id: 'test-id', + foo: 'test', + tags: { some: 'tag' }, + }) + + return expect(() => storageService.update(agentContext, record)).rejects.toThrowError(RecordNotFoundError) + }) + + it('should update the record', async () => { + const record = await insertRecord({ id: 'test-id' }) + + record.replaceTags({ ...record.getTags(), foo: 'bar' }) + record.foo = 'foobaz' + await storageService.update(agentContext, record) + + const retrievedRecord = await storageService.getById(agentContext, TestRecord, record.id) + expect(retrievedRecord).toEqual(record) + }) + }) + + describe('delete()', () => { + it('should throw RecordNotFoundError if the record does not exist', async () => { + const record = new TestRecord({ + id: 'test-id', + foo: 'test', + tags: { some: 'tag' }, + }) + + return expect(() => storageService.delete(agentContext, record)).rejects.toThrowError(RecordNotFoundError) + }) + + it('should delete the record', async () => { + const record = await insertRecord({ id: 'test-id' }) + await storageService.delete(agentContext, record) + + return expect(() => storageService.getById(agentContext, TestRecord, record.id)).rejects.toThrowError( + RecordNotFoundError + ) + }) + }) + + describe('getAll()', () => { + it('should retrieve all records', async () => { + const createdRecords = await Promise.all( + Array(5) + .fill(undefined) + .map((_, index) => insertRecord({ id: `record-${index}` })) + ) + + const records = await storageService.getAll(agentContext, TestRecord) + + expect(records).toEqual(expect.arrayContaining(createdRecords)) + }) + }) + + describe('findByQuery()', () => { + it('should retrieve all records that match the query', async () => { + const expectedRecord = await insertRecord({ tags: { myTag: 'foobar' } }) + const expectedRecord2 = await insertRecord({ tags: { myTag: 'foobar' } }) + await insertRecord({ tags: { myTag: 'notfoobar' } }) + + const records = await storageService.findByQuery(agentContext, TestRecord, { myTag: 'foobar' }) + + expect(records.length).toBe(2) + expect(records).toEqual(expect.arrayContaining([expectedRecord, expectedRecord2])) + }) + + it('finds records using $and statements', async () => { + const expectedRecord = await insertRecord({ tags: { myTag: 'foo', anotherTag: 'bar' } }) + await insertRecord({ tags: { myTag: 'notfoobar' } }) + + const records = await storageService.findByQuery(agentContext, TestRecord, { + $and: [{ myTag: 'foo' }, { anotherTag: 'bar' }], + }) + + expect(records.length).toBe(1) + expect(records[0]).toEqual(expectedRecord) + }) + + it('finds records using $or statements', async () => { + const expectedRecord = await insertRecord({ tags: { myTag: 'foo' } }) + const expectedRecord2 = await insertRecord({ tags: { anotherTag: 'bar' } }) + await insertRecord({ tags: { myTag: 'notfoobar' } }) + + const records = await storageService.findByQuery(agentContext, TestRecord, { + $or: [{ myTag: 'foo' }, { anotherTag: 'bar' }], + }) + + expect(records.length).toBe(2) + expect(records).toEqual(expect.arrayContaining([expectedRecord, expectedRecord2])) + }) + + it('finds records using $not statements', async () => { + const expectedRecord = await insertRecord({ tags: { myTag: 'foo' } }) + const expectedRecord2 = await insertRecord({ tags: { anotherTag: 'bar' } }) + await insertRecord({ tags: { myTag: 'notfoobar' } }) + + const records = await storageService.findByQuery(agentContext, TestRecord, { + $not: { myTag: 'notfoobar' }, + }) + + expect(records.length).toBe(2) + expect(records).toEqual(expect.arrayContaining([expectedRecord, expectedRecord2])) + }) + + it('correctly transforms an advanced query into a valid WQL query', async () => { + const expectedQuery = { + $and: [ + { + $and: undefined, + $not: undefined, + $or: [ + { myTag: '1', $and: undefined, $or: undefined, $not: undefined }, + { myTag: '0', $and: undefined, $or: undefined, $not: undefined }, + ], + }, + { + $or: undefined, + $not: undefined, + $and: [ + { theNumber: 'n__0', $and: undefined, $or: undefined, $not: undefined }, + { theNumber: 'n__1', $and: undefined, $or: undefined, $not: undefined }, + ], + }, + ], + $or: [ + { + 'aValue:foo': '1', + 'aValue:bar': '1', + $and: undefined, + $or: undefined, + $not: undefined, + }, + ], + $not: { myTag: 'notfoobar', $and: undefined, $or: undefined, $not: undefined }, + } + + expect( + askarQueryFromSearchQuery({ + $and: [ + { + $or: [{ myTag: true }, { myTag: false }], + }, + { + $and: [{ theNumber: '0' }, { theNumber: '1' }], + }, + ], + $or: [ + { + aValue: ['foo', 'bar'], + }, + ], + $not: { myTag: 'notfoobar' }, + }) + ).toEqual(expectedQuery) + }) + }) +}) diff --git a/packages/askar/src/storage/index.ts b/packages/askar/src/storage/index.ts new file mode 100644 index 0000000000..ac0265f1ea --- /dev/null +++ b/packages/askar/src/storage/index.ts @@ -0,0 +1 @@ +export * from './AskarStorageService' diff --git a/packages/askar/src/storage/utils.ts b/packages/askar/src/storage/utils.ts new file mode 100644 index 0000000000..381bd98dd7 --- /dev/null +++ b/packages/askar/src/storage/utils.ts @@ -0,0 +1,110 @@ +import type { BaseRecord, BaseRecordConstructor, Query, TagsBase } from '@aries-framework/core' +import type { EntryObject } from '@hyperledger/aries-askar-shared' + +import { JsonTransformer } from '@aries-framework/core' + +export function recordToInstance(record: EntryObject, recordClass: BaseRecordConstructor): T { + const instance = JsonTransformer.deserialize(record.value as string, recordClass) + instance.id = record.name + + const tags = record.tags ? transformToRecordTagValues(record.tags) : {} + instance.replaceTags(tags) + + return instance +} + +export function transformToRecordTagValues(tags: Record): TagsBase { + const transformedTags: TagsBase = {} + + for (const [key, value] of Object.entries(tags)) { + // If the value is a boolean string ('1' or '0') + // use the boolean val + if (value === '1' && key?.includes(':')) { + const [tagName, tagValue] = key.split(':') + + const transformedValue = transformedTags[tagName] + + if (Array.isArray(transformedValue)) { + transformedTags[tagName] = [...transformedValue, tagValue] + } else { + transformedTags[tagName] = [tagValue] + } + } + // Transform '1' and '0' to boolean + else if (value === '1' || value === '0') { + transformedTags[key] = value === '1' + } + // If 1 or 0 is prefixed with 'n__' we need to remove it. This is to prevent + // casting the value to a boolean + else if (value === 'n__1' || value === 'n__0') { + transformedTags[key] = value === 'n__1' ? '1' : '0' + } + // Otherwise just use the value + else { + transformedTags[key] = value as string + } + } + + return transformedTags +} + +export function transformFromRecordTagValues(tags: TagsBase): { [key: string]: string | undefined } { + const transformedTags: { [key: string]: string | undefined } = {} + + for (const [key, value] of Object.entries(tags)) { + // If the value is of type null we use the value undefined + // Askar doesn't support null as a value + if (value === null) { + transformedTags[key] = undefined + } + // If the value is a boolean use the Askar + // '1' or '0' syntax + else if (typeof value === 'boolean') { + transformedTags[key] = value ? '1' : '0' + } + // If the value is 1 or 0, we need to add something to the value, otherwise + // the next time we deserialize the tag values it will be converted to boolean + else if (value === '1' || value === '0') { + transformedTags[key] = `n__${value}` + } + // If the value is an array we create a tag for each array + // item ("tagName:arrayItem" = "1") + else if (Array.isArray(value)) { + value.forEach((item) => { + const tagName = `${key}:${item}` + transformedTags[tagName] = '1' + }) + } + // Otherwise just use the value + else { + transformedTags[key] = value + } + } + + return transformedTags +} + +/** + * Transforms the search query into a wallet query compatible with Askar WQL. + * + * The format used by AFJ is almost the same as the WQL query, with the exception of + * the encoding of values, however this is handled by the {@link AskarStorageServiceUtil.transformToRecordTagValues} + * method. + */ +export function askarQueryFromSearchQuery(query: Query): Record { + // eslint-disable-next-line prefer-const + let { $and, $or, $not, ...tags } = query + + $and = ($and as Query[] | undefined)?.map((q) => askarQueryFromSearchQuery(q)) + $or = ($or as Query[] | undefined)?.map((q) => askarQueryFromSearchQuery(q)) + $not = $not ? askarQueryFromSearchQuery($not as Query) : undefined + + const askarQuery = { + ...transformFromRecordTagValues(tags as unknown as TagsBase), + $and, + $or, + $not, + } + + return askarQuery +} diff --git a/packages/askar/src/types.ts b/packages/askar/src/types.ts new file mode 100644 index 0000000000..bc0baa2947 --- /dev/null +++ b/packages/askar/src/types.ts @@ -0,0 +1,3 @@ +import type { AriesAskar } from '@hyperledger/aries-askar-shared' + +export type { AriesAskar } diff --git a/packages/askar/src/utils/askarError.ts b/packages/askar/src/utils/askarError.ts new file mode 100644 index 0000000000..2cfcbd90cf --- /dev/null +++ b/packages/askar/src/utils/askarError.ts @@ -0,0 +1,16 @@ +import { AriesAskarError } from '@hyperledger/aries-askar-shared' + +export enum askarErrors { + Success = 0, + Backend = 1, + Busy = 2, + Duplicate = 3, + Encryption = 4, + Input = 5, + NotFound = 6, + Unexpected = 7, + Unsupported = 8, + Custom = 100, +} + +export const isAskarError = (error: Error) => error instanceof AriesAskarError diff --git a/packages/askar/src/utils/askarKeyTypes.ts b/packages/askar/src/utils/askarKeyTypes.ts new file mode 100644 index 0000000000..bb837f962e --- /dev/null +++ b/packages/askar/src/utils/askarKeyTypes.ts @@ -0,0 +1,6 @@ +import type { KeyType } from '@aries-framework/core' + +import { KeyAlgs } from '@hyperledger/aries-askar-shared' + +export const keyTypeSupportedByAskar = (keyType: KeyType) => + Object.entries(KeyAlgs).find(([, value]) => value === keyType.toString()) !== undefined diff --git a/packages/askar/src/utils/askarWalletConfig.ts b/packages/askar/src/utils/askarWalletConfig.ts new file mode 100644 index 0000000000..dcf1d15ab1 --- /dev/null +++ b/packages/askar/src/utils/askarWalletConfig.ts @@ -0,0 +1,76 @@ +import type { AskarWalletPostgresStorageConfig } from '../wallet/AskarWalletPostgresStorageConfig' +import type { WalletConfig } from '@aries-framework/core' + +import { KeyDerivationMethod, WalletError } from '@aries-framework/core' +import { StoreKeyMethod } from '@hyperledger/aries-askar-shared' + +export const keyDerivationMethodToStoreKeyMethod = (keyDerivationMethod?: KeyDerivationMethod) => { + if (!keyDerivationMethod) { + return undefined + } + + const correspondanceTable = { + [KeyDerivationMethod.Raw]: StoreKeyMethod.Raw, + [KeyDerivationMethod.Argon2IInt]: `${StoreKeyMethod.Kdf}:argon2i:int`, + [KeyDerivationMethod.Argon2IMod]: `${StoreKeyMethod.Kdf}:argon2i:mod`, + } + + return correspondanceTable[keyDerivationMethod] as StoreKeyMethod +} + +export const uriFromWalletConfig = (walletConfig: WalletConfig, basePath: string): { uri: string; path?: string } => { + let uri = '' + let path + + // By default use sqlite as database backend + if (!walletConfig.storage) { + walletConfig.storage = { type: 'sqlite' } + } + + if (walletConfig.storage.type === 'sqlite') { + if (walletConfig.storage.inMemory) { + uri = 'sqlite://:memory:' + } else { + path = `${(walletConfig.storage.path as string) ?? basePath + '/wallet'}/${walletConfig.id}/sqlite.db` + uri = `sqlite://${path}` + } + } else if (walletConfig.storage.type === 'postgres') { + const storageConfig = walletConfig.storage as unknown as AskarWalletPostgresStorageConfig + + if (!storageConfig.config || !storageConfig.credentials) { + throw new WalletError('Invalid storage configuration for postgres wallet') + } + + const urlParams = [] + if (storageConfig.config.connectTimeout !== undefined) { + urlParams.push(`connect_timeout=${encodeURIComponent(storageConfig.config.connectTimeout)}`) + } + if (storageConfig.config.idleTimeout !== undefined) { + urlParams.push(`idle_timeout=${encodeURIComponent(storageConfig.config.idleTimeout)}`) + } + if (storageConfig.config.maxConnections !== undefined) { + urlParams.push(`max_connections=${encodeURIComponent(storageConfig.config.maxConnections)}`) + } + if (storageConfig.config.minConnections !== undefined) { + urlParams.push(`min_connections=${encodeURIComponent(storageConfig.config.minConnections)}`) + } + if (storageConfig.credentials.adminAccount !== undefined) { + urlParams.push(`admin_account=${encodeURIComponent(storageConfig.credentials.adminAccount)}`) + } + if (storageConfig.credentials.adminPassword !== undefined) { + urlParams.push(`admin_password=${encodeURIComponent(storageConfig.credentials.adminPassword)}`) + } + + uri = `postgres://${encodeURIComponent(storageConfig.credentials.account)}:${encodeURIComponent( + storageConfig.credentials.password + )}@${storageConfig.config.host}/${encodeURIComponent(walletConfig.id)}` + + if (urlParams.length > 0) { + uri = `${uri}?${urlParams.join('&')}` + } + } else { + throw new WalletError(`Storage type not supported: ${walletConfig.storage.type}`) + } + + return { uri, path } +} diff --git a/packages/askar/src/utils/assertAskarWallet.ts b/packages/askar/src/utils/assertAskarWallet.ts new file mode 100644 index 0000000000..37213e3d28 --- /dev/null +++ b/packages/askar/src/utils/assertAskarWallet.ts @@ -0,0 +1,13 @@ +import type { Wallet } from '@aries-framework/core' + +import { AriesFrameworkError } from '@aries-framework/core' + +import { AskarWallet } from '../wallet/AskarWallet' + +export function assertAskarWallet(wallet: Wallet): asserts wallet is AskarWallet { + if (!(wallet instanceof AskarWallet)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const walletClassName = (wallet as any).constructor?.name ?? 'unknown' + throw new AriesFrameworkError(`Expected wallet to be instance of AskarWallet, found ${walletClassName}`) + } +} diff --git a/packages/askar/src/utils/index.ts b/packages/askar/src/utils/index.ts new file mode 100644 index 0000000000..b9f658de82 --- /dev/null +++ b/packages/askar/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './askarError' +export * from './askarKeyTypes' +export * from './askarWalletConfig' diff --git a/packages/askar/src/wallet/AskarWallet.ts b/packages/askar/src/wallet/AskarWallet.ts new file mode 100644 index 0000000000..432e50cdda --- /dev/null +++ b/packages/askar/src/wallet/AskarWallet.ts @@ -0,0 +1,748 @@ +import type { + EncryptedMessage, + WalletConfig, + WalletCreateKeyOptions, + DidConfig, + DidInfo, + WalletSignOptions, + UnpackedMessageContext, + WalletVerifyOptions, + Wallet, + WalletExportImportConfig, + WalletConfigRekey, + KeyPair, + KeyDerivationMethod, +} from '@aries-framework/core' +import type { Session } from '@hyperledger/aries-askar-shared' + +import { + JsonTransformer, + RecordNotFoundError, + RecordDuplicateError, + WalletInvalidKeyError, + WalletDuplicateError, + JsonEncoder, + KeyType, + Buffer, + AriesFrameworkError, + Logger, + WalletError, + InjectionSymbols, + Key, + SigningProviderRegistry, + TypedArrayEncoder, + FileSystem, + WalletNotFoundError, +} from '@aries-framework/core' +// eslint-disable-next-line import/order +import { + StoreKeyMethod, + KeyAlgs, + CryptoBox, + Store, + Key as AskarKey, + keyAlgFromString, +} from '@hyperledger/aries-askar-shared' + +const isError = (error: unknown): error is Error => error instanceof Error + +import { inject, injectable } from 'tsyringe' + +import { encodeToBase58, decodeFromBase58 } from '../../../core/src/utils/base58' +import { + askarErrors, + isAskarError, + keyDerivationMethodToStoreKeyMethod, + keyTypeSupportedByAskar, + uriFromWalletConfig, +} from '../utils' + +import { JweEnvelope, JweRecipient } from './JweEnvelope' + +@injectable() +export class AskarWallet implements Wallet { + private walletConfig?: WalletConfig + private _session?: Session + + private _store?: Store + + private logger: Logger + private fileSystem: FileSystem + + private signingKeyProviderRegistry: SigningProviderRegistry + private publicDidInfo: DidInfo | undefined + + public constructor( + @inject(InjectionSymbols.Logger) logger: Logger, + @inject(InjectionSymbols.FileSystem) fileSystem: FileSystem, + signingKeyProviderRegistry: SigningProviderRegistry + ) { + this.logger = logger + this.fileSystem = fileSystem + this.signingKeyProviderRegistry = signingKeyProviderRegistry + } + + public get isProvisioned() { + return this.walletConfig !== undefined + } + + public get isInitialized() { + return this._store !== undefined + } + + public get publicDid() { + return this.publicDidInfo + } + + public get store() { + if (!this._store) { + throw new AriesFrameworkError( + 'Wallet has not been initialized yet. Make sure to await agent.initialize() before using the agent.' + ) + } + + return this._store + } + + public get session() { + if (!this._session) { + throw new AriesFrameworkError('No Wallet Session is opened') + } + + return this._session + } + + public get masterSecretId() { + if (!this.isInitialized || !(this.walletConfig?.id || this.walletConfig?.masterSecretId)) { + throw new AriesFrameworkError( + 'Wallet has not been initialized yet. Make sure to await agent.initialize() before using the agent.' + ) + } + + return this.walletConfig?.masterSecretId ?? this.walletConfig.id + } + + /** + * Dispose method is called when an agent context is disposed. + */ + public async dispose() { + if (this.isInitialized) { + await this.close() + } + } + + /** + * @throws {WalletDuplicateError} if the wallet already exists + * @throws {WalletError} if another error occurs + */ + public async create(walletConfig: WalletConfig): Promise { + await this.createAndOpen(walletConfig) + await this.close() + } + + /** + * @throws {WalletDuplicateError} if the wallet already exists + * @throws {WalletError} if another error occurs + */ + public async createAndOpen(walletConfig: WalletConfig): Promise { + this.logger.debug(`Creating wallet '${walletConfig.id}`) + + const askarWalletConfig = await this.getAskarWalletConfig(walletConfig) + try { + this._store = await Store.provision({ + recreate: false, + uri: askarWalletConfig.uri, + profile: askarWalletConfig.profile, + keyMethod: askarWalletConfig.keyMethod, + passKey: askarWalletConfig.passKey, + }) + this.walletConfig = walletConfig + this._session = await this._store.openSession() + + // TODO: Master Secret creation (now part of IndyCredx/AnonCreds) + } catch (error) { + // FIXME: Askar should throw a Duplicate error code, but is currently returning Encryption + // And if we provide the very same wallet key, it will open it without any error + if (isAskarError(error) && (error.code === askarErrors.Encryption || error.code === askarErrors.Duplicate)) { + const errorMessage = `Wallet '${walletConfig.id}' already exists` + this.logger.debug(errorMessage) + + throw new WalletDuplicateError(errorMessage, { + walletType: 'AskarWallet', + cause: error, + }) + } + + const errorMessage = `Error creating wallet '${walletConfig.id}'` + this.logger.error(errorMessage, { + error, + errorMessage: error.message, + }) + + throw new WalletError(errorMessage, { cause: error }) + } + + this.logger.debug(`Successfully created wallet '${walletConfig.id}'`) + } + + /** + * @throws {WalletNotFoundError} if the wallet does not exist + * @throws {WalletError} if another error occurs + */ + public async open(walletConfig: WalletConfig): Promise { + await this._open(walletConfig) + } + + /** + * @throws {WalletNotFoundError} if the wallet does not exist + * @throws {WalletError} if another error occurs + */ + public async rotateKey(walletConfig: WalletConfigRekey): Promise { + if (!walletConfig.rekey) { + throw new WalletError('Wallet rekey undefined!. Please specify the new wallet key') + } + await this._open( + { + id: walletConfig.id, + key: walletConfig.key, + keyDerivationMethod: walletConfig.keyDerivationMethod, + }, + walletConfig.rekey, + walletConfig.rekeyDerivationMethod + ) + } + + /** + * @throws {WalletNotFoundError} if the wallet does not exist + * @throws {WalletError} if another error occurs + */ + private async _open( + walletConfig: WalletConfig, + rekey?: string, + rekeyDerivation?: KeyDerivationMethod + ): Promise { + if (this._store) { + throw new WalletError( + 'Wallet instance already opened. Close the currently opened wallet before re-opening the wallet' + ) + } + + const askarWalletConfig = await this.getAskarWalletConfig(walletConfig) + + try { + this._store = await Store.open({ + uri: askarWalletConfig.uri, + keyMethod: askarWalletConfig.keyMethod, + passKey: askarWalletConfig.passKey, + }) + + if (rekey) { + await this._store.rekey({ + passKey: rekey, + keyMethod: keyDerivationMethodToStoreKeyMethod(rekeyDerivation) ?? StoreKeyMethod.Raw, + }) + } + this._session = await this._store.openSession() + + this.walletConfig = walletConfig + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.NotFound) { + const errorMessage = `Wallet '${walletConfig.id}' not found` + this.logger.debug(errorMessage) + + throw new WalletNotFoundError(errorMessage, { + walletType: 'AskarWallet', + cause: error, + }) + } else if (isAskarError(error) && error.code === askarErrors.Encryption) { + const errorMessage = `Incorrect key for wallet '${walletConfig.id}'` + this.logger.debug(errorMessage) + throw new WalletInvalidKeyError(errorMessage, { + walletType: 'AskarWallet', + cause: error, + }) + } + throw new WalletError( + `Error opening wallet ${walletConfig.id}. ERROR CODE ${error.code} MESSAGE ${error.message}`, + { cause: error } + ) + } + + this.logger.debug(`Wallet '${walletConfig.id}' opened with handle '${this._store.handle.handle}'`) + } + + /** + * @throws {WalletNotFoundError} if the wallet does not exist + * @throws {WalletError} if another error occurs + */ + public async delete(): Promise { + if (!this.walletConfig) { + throw new WalletError( + 'Can not delete wallet that does not have wallet config set. Make sure to call create wallet before deleting the wallet' + ) + } + + this.logger.info(`Deleting wallet '${this.walletConfig.id}'`) + + if (this._store) { + await this.close() + } + + try { + const { uri } = uriFromWalletConfig(this.walletConfig, this.fileSystem.basePath) + await Store.remove(uri) + } catch (error) { + const errorMessage = `Error deleting wallet '${this.walletConfig.id}': ${error.message}` + this.logger.error(errorMessage, { + error, + errorMessage: error.message, + }) + + throw new WalletError(errorMessage, { cause: error }) + } + } + + public async export(exportConfig: WalletExportImportConfig) { + // TODO + throw new WalletError('AskarWallet Export not yet implemented') + } + + public async import(walletConfig: WalletConfig, importConfig: WalletExportImportConfig) { + // TODO + throw new WalletError('AskarWallet Import not yet implemented') + } + + /** + * @throws {WalletError} if the wallet is already closed or another error occurs + */ + public async close(): Promise { + this.logger.debug(`Closing wallet ${this.walletConfig?.id}`) + if (!this._store) { + throw new WalletError('Wallet is in invalid state, you are trying to close wallet that has no handle.') + } + + try { + await this.session.close() + await this.store.close() + this._session = undefined + this._store = undefined + this.publicDidInfo = undefined + } catch (error) { + const errorMessage = `Error closing wallet': ${error.message}` + this.logger.error(errorMessage, { + error, + errorMessage: error.message, + }) + + throw new WalletError(errorMessage, { cause: error }) + } + } + + public async initPublicDid(didConfig: DidConfig) { + // Not implemented, as it does not work with legacy Ledger module + } + + /** + * Create a key with an optional seed and keyType. + * The keypair is also automatically stored in the wallet afterwards + * + * @param seed string The seed for creating a key + * @param keyType KeyType the type of key that should be created + * + * @returns a Key instance with a publicKeyBase58 + * + * @throws {WalletError} When an unsupported keytype is requested + * @throws {WalletError} When the key could not be created + */ + public async createKey({ seed, keyType }: WalletCreateKeyOptions): Promise { + try { + if (keyTypeSupportedByAskar(keyType)) { + const algorithm = keyAlgFromString(keyType) + + // Create key from seed + const key = seed ? AskarKey.fromSeed({ seed: Buffer.from(seed), algorithm }) : AskarKey.generate(algorithm) + + // Store key + await this.session.insertKey({ key, name: encodeToBase58(key.publicBytes) }) + return Key.fromPublicKey(key.publicBytes, keyType) + } else { + // Check if there is a signing key provider for the specified key type. + if (this.signingKeyProviderRegistry.hasProviderForKeyType(keyType)) { + const signingKeyProvider = this.signingKeyProviderRegistry.getProviderForKeyType(keyType) + + const keyPair = await signingKeyProvider.createKeyPair({ seed }) + await this.storeKeyPair(keyPair) + return Key.fromPublicKeyBase58(keyPair.publicKeyBase58, keyType) + } + throw new WalletError(`Unsupported key type: '${keyType}'`) + } + } catch (error) { + if (!isError(error)) { + throw new AriesFrameworkError('Attempted to throw error, but it was not of type Error', { cause: error }) + } + throw new WalletError(`Error creating key with key type '${keyType}': ${error.message}`, { cause: error }) + } + } + + /** + * sign a Buffer with an instance of a Key class + * + * @param data Buffer The data that needs to be signed + * @param key Key The key that is used to sign the data + * + * @returns A signature for the data + */ + public async sign({ data, key }: WalletSignOptions): Promise { + try { + if (keyTypeSupportedByAskar(key.keyType)) { + if (!TypedArrayEncoder.isTypedArray(data)) { + throw new WalletError(`Currently not supporting signing of multiple messages`) + } + + const keyEntry = await this.session.fetchKey({ name: key.publicKeyBase58 }) + + if (!keyEntry) { + throw new WalletError('Key entry not found') + } + + const signed = keyEntry.key.signMessage({ message: data as Buffer }) + + return Buffer.from(signed) + } else { + // Check if there is a signing key provider for the specified key type. + if (this.signingKeyProviderRegistry.hasProviderForKeyType(key.keyType)) { + const signingKeyProvider = this.signingKeyProviderRegistry.getProviderForKeyType(key.keyType) + + const keyPair = await this.retrieveKeyPair(key.publicKeyBase58) + const signed = await signingKeyProvider.sign({ + data, + privateKeyBase58: keyPair.privateKeyBase58, + publicKeyBase58: key.publicKeyBase58, + }) + + return signed + } + throw new WalletError(`Unsupported keyType: ${key.keyType}`) + } + } catch (error) { + if (!isError(error)) { + throw new AriesFrameworkError('Attempted to throw error, but it was not of type Error', { cause: error }) + } + throw new WalletError(`Error signing data with verkey ${key.publicKeyBase58}`, { cause: error }) + } + } + + /** + * Verify the signature with the data and the used key + * + * @param data Buffer The data that has to be confirmed to be signed + * @param key Key The key that was used in the signing process + * @param signature Buffer The signature that was created by the signing process + * + * @returns A boolean whether the signature was created with the supplied data and key + * + * @throws {WalletError} When it could not do the verification + * @throws {WalletError} When an unsupported keytype is used + */ + public async verify({ data, key, signature }: WalletVerifyOptions): Promise { + try { + if (keyTypeSupportedByAskar(key.keyType)) { + if (!TypedArrayEncoder.isTypedArray(data)) { + throw new WalletError(`Currently not supporting verification of multiple messages`) + } + + const askarKey = AskarKey.fromPublicBytes({ + algorithm: keyAlgFromString(key.keyType), + publicKey: key.publicKey, + }) + return askarKey.verifySignature({ message: data as Buffer, signature }) + } else { + // Check if there is a signing key provider for the specified key type. + if (this.signingKeyProviderRegistry.hasProviderForKeyType(key.keyType)) { + const signingKeyProvider = this.signingKeyProviderRegistry.getProviderForKeyType(key.keyType) + + const signed = await signingKeyProvider.verify({ + data, + signature, + publicKeyBase58: key.publicKeyBase58, + }) + + return signed + } + throw new WalletError(`Unsupported keyType: ${key.keyType}`) + } + } catch (error) { + if (!isError(error)) { + throw new AriesFrameworkError('Attempted to throw error, but it was not of type Error', { cause: error }) + } + throw new WalletError(`Error verifying signature of data signed with verkey ${key.publicKeyBase58}`, { + cause: error, + }) + } + } + + /** + * Pack a message using DIDComm V1 algorithm + * + * @param payload message to send + * @param recipientKeys array containing recipient keys in base58 + * @param senderVerkey sender key in base58 + * @returns JWE Envelope to send + */ + public async pack( + payload: Record, + recipientKeys: string[], + senderVerkey?: string // in base58 + ): Promise { + const cek = AskarKey.generate(KeyAlgs.Chacha20C20P) + + const senderKey = senderVerkey ? await this.session.fetchKey({ name: senderVerkey }) : undefined + + const senderExchangeKey = senderKey ? senderKey.key.convertkey({ algorithm: KeyAlgs.X25519 }) : undefined + + const recipients: JweRecipient[] = [] + + for (const recipientKey of recipientKeys) { + const targetExchangeKey = AskarKey.fromPublicBytes({ + publicKey: Key.fromPublicKeyBase58(recipientKey, KeyType.Ed25519).publicKey, + algorithm: KeyAlgs.Ed25519, + }).convertkey({ algorithm: KeyAlgs.X25519 }) + + if (senderVerkey && senderExchangeKey) { + const encryptedSender = CryptoBox.seal({ + recipientKey: targetExchangeKey, + message: Buffer.from(senderVerkey), + }) + const nonce = CryptoBox.randomNonce() + const encryptedCek = CryptoBox.cryptoBox({ + recipientKey: targetExchangeKey, + senderKey: senderExchangeKey, + message: cek.secretBytes, + nonce, + }) + + recipients.push( + new JweRecipient({ + encryptedKey: encryptedCek, + header: { + kid: recipientKey, + sender: TypedArrayEncoder.toBase64URL(encryptedSender), + iv: TypedArrayEncoder.toBase64URL(nonce), + }, + }) + ) + } else { + const encryptedCek = CryptoBox.seal({ + recipientKey: targetExchangeKey, + message: cek.secretBytes, + }) + recipients.push( + new JweRecipient({ + encryptedKey: encryptedCek, + header: { + kid: recipientKey, + }, + }) + ) + } + } + + const protectedJson = { + enc: 'xchacha20poly1305_ietf', + typ: 'JWM/1.0', + alg: senderVerkey ? 'Authcrypt' : 'Anoncrypt', + recipients: recipients.map((item) => JsonTransformer.toJSON(item)), + } + + const { ciphertext, tag, nonce } = cek.aeadEncrypt({ + message: Buffer.from(JSON.stringify(payload)), + aad: Buffer.from(JsonEncoder.toBase64URL(protectedJson)), + }).parts + + const envelope = new JweEnvelope({ + ciphertext: TypedArrayEncoder.toBase64URL(ciphertext), + iv: TypedArrayEncoder.toBase64URL(nonce), + protected: JsonEncoder.toBase64URL(protectedJson), + tag: TypedArrayEncoder.toBase64URL(tag), + }).toJson() + + return envelope as EncryptedMessage + } + + /** + * Unpacks a JWE Envelope coded using DIDComm V1 algorithm + * + * @param messagePackage JWE Envelope + * @returns UnpackedMessageContext with plain text message, sender key and recipient key + */ + public async unpack(messagePackage: EncryptedMessage): Promise { + const protectedJson = JsonEncoder.fromBase64(messagePackage.protected) + + const alg = protectedJson.alg + const isAuthcrypt = alg === 'Authcrypt' + + if (!isAuthcrypt && alg != 'Anoncrypt') { + throw new WalletError(`Unsupported pack algorithm: ${alg}`) + } + + const recipients = [] + + for (const recip of protectedJson.recipients) { + const kid = recip.header.kid + if (!kid) { + throw new WalletError('Blank recipient key') + } + const sender = recip.header.sender ? TypedArrayEncoder.fromBase64(recip.header.sender) : undefined + const iv = recip.header.iv ? TypedArrayEncoder.fromBase64(recip.header.iv) : undefined + if (sender && !iv) { + throw new WalletError('Missing IV') + } else if (!sender && iv) { + throw new WalletError('Unexpected IV') + } + recipients.push({ + kid, + sender, + iv, + encrypted_key: TypedArrayEncoder.fromBase64(recip.encrypted_key), + }) + } + + let payloadKey, senderKey, recipientKey + + for (const recipient of recipients) { + let recipientKeyEntry + try { + recipientKeyEntry = await this.session.fetchKey({ name: recipient.kid }) + } catch (error) { + // TODO: Currently Askar wrapper throws error when key is not found + // In this case we don't need to throw any error because we should + // try with other recipient keys + continue + } + if (recipientKeyEntry) { + const recip_x = recipientKeyEntry.key.convertkey({ algorithm: KeyAlgs.X25519 }) + recipientKey = recipient.kid + + if (recipient.sender && recipient.iv) { + senderKey = TypedArrayEncoder.toUtf8String( + CryptoBox.sealOpen({ + recipientKey: recip_x, + ciphertext: recipient.sender, + }) + ) + const sender_x = AskarKey.fromPublicBytes({ + algorithm: KeyAlgs.Ed25519, + publicKey: decodeFromBase58(senderKey), + }).convertkey({ algorithm: KeyAlgs.X25519 }) + + payloadKey = CryptoBox.open({ + recipientKey: recip_x, + senderKey: sender_x, + message: recipient.encrypted_key, + nonce: recipient.iv, + }) + } + break + } + } + if (!payloadKey) { + throw new WalletError('No corresponding recipient key found') + } + + if (!senderKey && isAuthcrypt) { + throw new WalletError('Sender public key not provided for Authcrypt') + } + + const cek = AskarKey.fromSecretBytes({ algorithm: KeyAlgs.Chacha20C20P, secretKey: payloadKey }) + const message = cek.aeadDecrypt({ + ciphertext: TypedArrayEncoder.fromBase64(messagePackage.ciphertext as any), + nonce: TypedArrayEncoder.fromBase64(messagePackage.iv as any), + tag: TypedArrayEncoder.fromBase64(messagePackage.tag as any), + aad: TypedArrayEncoder.fromString(messagePackage.protected), + }) + return { + plaintextMessage: JsonEncoder.fromBuffer(message), + senderKey, + recipientKey, + } + } + + public async generateNonce(): Promise { + try { + return TypedArrayEncoder.toUtf8String(CryptoBox.randomNonce()) + } catch (error) { + if (!isError(error)) { + throw new AriesFrameworkError('Attempted to throw error, but it was not of type Error', { cause: error }) + } + throw new WalletError('Error generating nonce', { cause: error }) + } + } + + public async generateWalletKey() { + try { + return Store.generateRawKey() + } catch (error) { + throw new WalletError('Error generating wallet key', { cause: error }) + } + } + + private async getAskarWalletConfig(walletConfig: WalletConfig) { + const { uri, path } = uriFromWalletConfig(walletConfig, this.fileSystem.basePath) + + // Make sure path exists before creating the wallet + if (path) { + await this.fileSystem.createDirectory(path) + } + + return { + uri, + profile: walletConfig.id, + // FIXME: Default derivation method should be set somewhere in either agent config or some constants + keyMethod: keyDerivationMethodToStoreKeyMethod(walletConfig.keyDerivationMethod) ?? StoreKeyMethod.None, + passKey: walletConfig.key, + } + } + + private async retrieveKeyPair(publicKeyBase58: string): Promise { + try { + const entryObject = await this.session.fetch({ category: 'KeyPairRecord', name: `key-${publicKeyBase58}` }) + + if (entryObject?.value) { + return JsonEncoder.fromString(entryObject?.value as string) as KeyPair + } else { + throw new WalletError(`No content found for record with public key: ${publicKeyBase58}`) + } + } catch (error) { + if ( + isAskarError(error) && + (error.code === askarErrors.NotFound || + // FIXME: this is current output from askar wrapper but does not describe specifically a not found scenario + error.message === 'Received null pointer. The native library could not find the value.') + ) { + throw new RecordNotFoundError(`KeyPairRecord not found for public key: ${publicKeyBase58}.`, { + recordType: 'KeyPairRecord', + cause: error, + }) + } + throw new WalletError('Error retrieving KeyPair record', { cause: error }) + } + } + + private async storeKeyPair(keyPair: KeyPair): Promise { + try { + await this.session.insert({ + category: 'KeyPairRecord', + name: `key-${keyPair.publicKeyBase58}`, + value: JSON.stringify(keyPair), + tags: { + keyType: keyPair.keyType, + }, + }) + } catch (error) { + if (isAskarError(error) && error.code === askarErrors.Duplicate) { + throw new RecordDuplicateError(`Record already exists`, { recordType: 'KeyPairRecord' }) + } + throw new WalletError('Error saving KeyPair record', { cause: error }) + } + } +} diff --git a/packages/askar/src/wallet/AskarWalletPostgresStorageConfig.ts b/packages/askar/src/wallet/AskarWalletPostgresStorageConfig.ts new file mode 100644 index 0000000000..a9a9aab91f --- /dev/null +++ b/packages/askar/src/wallet/AskarWalletPostgresStorageConfig.ts @@ -0,0 +1,22 @@ +import type { WalletStorageConfig } from '../../../core/src/types' + +export interface AskarWalletPostgresConfig { + host: string + connectTimeout?: number + idleTimeout?: number + maxConnections?: number + minConnections?: number +} + +export interface AskarWalletPostgresCredentials { + account: string + password: string + adminAccount?: string + adminPassword?: string +} + +export interface AskarWalletPostgresStorageConfig extends WalletStorageConfig { + type: 'postgres' + config: AskarWalletPostgresConfig + credentials: AskarWalletPostgresCredentials +} diff --git a/packages/askar/src/wallet/JweEnvelope.ts b/packages/askar/src/wallet/JweEnvelope.ts new file mode 100644 index 0000000000..ac4d791f89 --- /dev/null +++ b/packages/askar/src/wallet/JweEnvelope.ts @@ -0,0 +1,62 @@ +import { JsonTransformer, TypedArrayEncoder } from '@aries-framework/core' +import { Expose, Type } from 'class-transformer' + +export class JweRecipient { + @Expose({ name: 'encrypted_key' }) + public encryptedKey!: string + public header?: Record + + public constructor(options: { encryptedKey: Uint8Array; header?: Record }) { + if (options) { + this.encryptedKey = TypedArrayEncoder.toBase64URL(options.encryptedKey) + + this.header = options.header + } + } +} + +export interface JweEnvelopeOptions { + protected: string + unprotected?: string + recipients?: JweRecipient[] + ciphertext: string + iv: string + tag: string + aad?: string + header?: string[] + encryptedKey?: string +} + +export class JweEnvelope { + public protected!: string + public unprotected?: string + + @Type(() => JweRecipient) + public recipients?: JweRecipient[] + public ciphertext!: string + public iv!: string + public tag!: string + public aad?: string + public header?: string[] + + @Expose({ name: 'encrypted_key' }) + public encryptedKey?: string + + public constructor(options: JweEnvelopeOptions) { + if (options) { + this.protected = options.protected + this.unprotected = options.unprotected + this.recipients = options.recipients + this.ciphertext = options.ciphertext + this.iv = options.iv + this.tag = options.tag + this.aad = options.aad + this.header = options.header + this.encryptedKey = options.encryptedKey + } + } + + public toJson() { + return JsonTransformer.toJSON(this) + } +} diff --git a/packages/askar/src/wallet/__tests__/AskarWallet.test.ts b/packages/askar/src/wallet/__tests__/AskarWallet.test.ts new file mode 100644 index 0000000000..15bbf174cd --- /dev/null +++ b/packages/askar/src/wallet/__tests__/AskarWallet.test.ts @@ -0,0 +1,252 @@ +import type { + SigningProvider, + WalletConfig, + CreateKeyPairOptions, + KeyPair, + SignOptions, + VerifyOptions, +} from '@aries-framework/core' + +import { + WalletError, + WalletDuplicateError, + WalletNotFoundError, + WalletInvalidKeyError, + KeyType, + SigningProviderRegistry, + TypedArrayEncoder, + KeyDerivationMethod, + Buffer, +} from '@aries-framework/core' +import { Store } from '@hyperledger/aries-askar-shared' + +import { encodeToBase58 } from '../../../../core/src/utils/base58' +import { agentDependencies } from '../../../../core/tests/helpers' +import testLogger from '../../../../core/tests/logger' +import { AskarWallet } from '../AskarWallet' + +// use raw key derivation method to speed up wallet creating / opening / closing between tests +const walletConfig: WalletConfig = { + id: 'Wallet: AskarWalletTest', + // generated using indy.generateWalletKey + key: 'CwNJroKHTSSj3XvE7ZAnuKiTn2C4QkFvxEqfm5rzhNrb', + keyDerivationMethod: KeyDerivationMethod.Raw, +} + +describe('AskarWallet basic operations', () => { + let askarWallet: AskarWallet + + const seed = 'sample-seed' + const message = TypedArrayEncoder.fromString('sample-message') + + beforeEach(async () => { + askarWallet = new AskarWallet(testLogger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + await askarWallet.createAndOpen(walletConfig) + }) + + afterEach(async () => { + await askarWallet.delete() + }) + + test('Get the Master Secret', () => { + expect(askarWallet.masterSecretId).toEqual('Wallet: AskarWalletTest') + }) + + test('Get the wallet store', () => { + expect(askarWallet.store).toEqual(expect.any(Store)) + }) + + test('Generate Nonce', async () => { + await expect(askarWallet.generateNonce()).resolves.toEqual(expect.any(String)) + }) + + test('Create ed25519 keypair', async () => { + await expect( + askarWallet.createKey({ seed: '2103de41b4ae37e8e28586d84a342b67', keyType: KeyType.Ed25519 }) + ).resolves.toMatchObject({ + keyType: KeyType.Ed25519, + }) + }) + + test('Create x25519 keypair', async () => { + await expect(askarWallet.createKey({ seed, keyType: KeyType.X25519 })).resolves.toMatchObject({ + keyType: KeyType.X25519, + }) + }) + + describe.skip('Currently, all KeyTypes are supported by Askar natively', () => { + test('Fail to create a Bls12381g1g2 keypair', async () => { + await expect(askarWallet.createKey({ seed, keyType: KeyType.Bls12381g1g2 })).rejects.toThrowError(WalletError) + }) + }) + + test('Create a signature with a ed25519 keypair', async () => { + const ed25519Key = await askarWallet.createKey({ keyType: KeyType.Ed25519 }) + const signature = await askarWallet.sign({ + data: message, + key: ed25519Key, + }) + expect(signature.length).toStrictEqual(64) + }) + + test('Verify a signed message with a ed25519 publicKey', async () => { + const ed25519Key = await askarWallet.createKey({ keyType: KeyType.Ed25519 }) + const signature = await askarWallet.sign({ + data: message, + key: ed25519Key, + }) + await expect(askarWallet.verify({ key: ed25519Key, data: message, signature })).resolves.toStrictEqual(true) + }) + + test('masterSecretId is equal to wallet ID by default', async () => { + expect(askarWallet.masterSecretId).toEqual(walletConfig.id) + }) +}) + +describe.skip('Currently, all KeyTypes are supported by Askar natively', () => { + describe('AskarWallet with custom signing provider', () => { + let askarWallet: AskarWallet + + const seed = 'sample-seed' + const message = TypedArrayEncoder.fromString('sample-message') + + class DummySigningProvider implements SigningProvider { + public keyType: KeyType = KeyType.Bls12381g1g2 + + public async createKeyPair(options: CreateKeyPairOptions): Promise { + return { + publicKeyBase58: encodeToBase58(Buffer.from(options.seed || 'publicKeyBase58')), + privateKeyBase58: 'privateKeyBase58', + keyType: KeyType.Bls12381g1g2, + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async sign(options: SignOptions): Promise { + return new Buffer('signed') + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public async verify(options: VerifyOptions): Promise { + return true + } + } + + beforeEach(async () => { + askarWallet = new AskarWallet( + testLogger, + new agentDependencies.FileSystem(), + new SigningProviderRegistry([new DummySigningProvider()]) + ) + await askarWallet.createAndOpen(walletConfig) + }) + + afterEach(async () => { + await askarWallet.delete() + }) + + test('Create custom keypair and use it for signing', async () => { + const key = await askarWallet.createKey({ seed, keyType: KeyType.Bls12381g1g2 }) + expect(key.keyType).toBe(KeyType.Bls12381g1g2) + expect(key.publicKeyBase58).toBe(encodeToBase58(Buffer.from(seed))) + + const signature = await askarWallet.sign({ + data: message, + key, + }) + + expect(signature).toBeInstanceOf(Buffer) + }) + + test('Create custom keypair and use it for verifying', async () => { + const key = await askarWallet.createKey({ seed, keyType: KeyType.Bls12381g1g2 }) + expect(key.keyType).toBe(KeyType.Bls12381g1g2) + expect(key.publicKeyBase58).toBe(encodeToBase58(Buffer.from(seed))) + + const signature = await askarWallet.verify({ + data: message, + signature: new Buffer('signature'), + key, + }) + + expect(signature).toBeTruthy() + }) + + test('Attempt to create the same custom keypair twice', async () => { + await askarWallet.createKey({ seed: 'keybase58', keyType: KeyType.Bls12381g1g2 }) + + await expect(askarWallet.createKey({ seed: 'keybase58', keyType: KeyType.Bls12381g1g2 })).rejects.toThrow( + WalletError + ) + }) + }) +}) + +describe('AskarWallet management', () => { + let askarWallet: AskarWallet + + afterEach(async () => { + if (askarWallet) { + await askarWallet.delete() + } + }) + + test('Create', async () => { + askarWallet = new AskarWallet(testLogger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + + const initialKey = Store.generateRawKey() + const anotherKey = Store.generateRawKey() + + // Create and open wallet + await askarWallet.createAndOpen({ ...walletConfig, id: 'AskarWallet Create', key: initialKey }) + + // Close and try to re-create it + await askarWallet.close() + await expect( + askarWallet.createAndOpen({ ...walletConfig, id: 'AskarWallet Create', key: anotherKey }) + ).rejects.toThrowError(WalletDuplicateError) + }) + + test('Open', async () => { + askarWallet = new AskarWallet(testLogger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + + const initialKey = Store.generateRawKey() + const wrongKey = Store.generateRawKey() + + // Create and open wallet + await askarWallet.createAndOpen({ ...walletConfig, id: 'AskarWallet Open', key: initialKey }) + + // Close and try to re-opening it with a wrong key + await askarWallet.close() + await expect(askarWallet.open({ ...walletConfig, id: 'AskarWallet Open', key: wrongKey })).rejects.toThrowError( + WalletInvalidKeyError + ) + + // Try to open a non existent wallet + await expect( + askarWallet.open({ ...walletConfig, id: 'AskarWallet Open - Non existent', key: initialKey }) + ).rejects.toThrowError(WalletNotFoundError) + }) + + test('Rotate key', async () => { + askarWallet = new AskarWallet(testLogger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + + const initialKey = Store.generateRawKey() + await askarWallet.createAndOpen({ ...walletConfig, id: 'AskarWallet Key Rotation', key: initialKey }) + + await askarWallet.close() + + const newKey = Store.generateRawKey() + await askarWallet.rotateKey({ ...walletConfig, id: 'AskarWallet Key Rotation', key: initialKey, rekey: newKey }) + + await askarWallet.close() + + await expect( + askarWallet.open({ ...walletConfig, id: 'AskarWallet Key Rotation', key: initialKey }) + ).rejects.toThrowError(WalletInvalidKeyError) + + await askarWallet.open({ ...walletConfig, id: 'AskarWallet Key Rotation', key: newKey }) + + await askarWallet.close() + }) +}) diff --git a/packages/askar/src/wallet/__tests__/packing.test.ts b/packages/askar/src/wallet/__tests__/packing.test.ts new file mode 100644 index 0000000000..2a27e18678 --- /dev/null +++ b/packages/askar/src/wallet/__tests__/packing.test.ts @@ -0,0 +1,52 @@ +import type { WalletConfig } from '@aries-framework/core' + +import { + JsonTransformer, + BasicMessage, + KeyType, + SigningProviderRegistry, + KeyDerivationMethod, +} from '@aries-framework/core' + +import { agentDependencies } from '../../../../core/tests/helpers' +import testLogger from '../../../../core/tests/logger' +import { AskarWallet } from '../AskarWallet' + +// use raw key derivation method to speed up wallet creating / opening / closing between tests +const walletConfig: WalletConfig = { + id: 'Askar Wallet Packing', + // generated using indy.generateWalletKey + key: 'CwNJroKHTSSj3XvE7ZAnuKiTn2C4QkFvxEqfm5rzhNrb', + keyDerivationMethod: KeyDerivationMethod.Raw, +} + +describe('askarWallet packing', () => { + let askarWallet: AskarWallet + + beforeEach(async () => { + askarWallet = new AskarWallet(testLogger, new agentDependencies.FileSystem(), new SigningProviderRegistry([])) + await askarWallet.createAndOpen(walletConfig) + }) + + afterEach(async () => { + await askarWallet.delete() + }) + + test('DIDComm V1 packing and unpacking', async () => { + // Create both sender and recipient keys + const senderKey = await askarWallet.createKey({ keyType: KeyType.Ed25519 }) + const recipientKey = await askarWallet.createKey({ keyType: KeyType.Ed25519 }) + + const message = new BasicMessage({ content: 'hello' }) + + const encryptedMessage = await askarWallet.pack( + message.toJSON(), + [recipientKey.publicKeyBase58], + senderKey.publicKeyBase58 + ) + + const plainTextMessage = await askarWallet.unpack(encryptedMessage) + + expect(JsonTransformer.fromJSON(plainTextMessage.plaintextMessage, BasicMessage)).toEqual(message) + }) +}) diff --git a/packages/askar/src/wallet/index.ts b/packages/askar/src/wallet/index.ts new file mode 100644 index 0000000000..8d569fdf4c --- /dev/null +++ b/packages/askar/src/wallet/index.ts @@ -0,0 +1,2 @@ +export { AskarWallet } from './AskarWallet' +export * from './AskarWalletPostgresStorageConfig' diff --git a/packages/askar/tests/askar-postgres.e2e.test.ts b/packages/askar/tests/askar-postgres.e2e.test.ts new file mode 100644 index 0000000000..dfbc6db600 --- /dev/null +++ b/packages/askar/tests/askar-postgres.e2e.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { SubjectMessage } from '../../../tests/transport/SubjectInboundTransport' +import type { AskarWalletPostgresStorageConfig } from '../src/wallet' +import type { ConnectionRecord } from '@aries-framework/core' + +import { Agent, HandshakeProtocol } from '@aries-framework/core' +import { Subject } from 'rxjs' + +import { SubjectInboundTransport } from '../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransport } from '../../../tests/transport/SubjectOutboundTransport' +import { waitForBasicMessage } from '../../core/tests/helpers' + +import { getPostgresAgentOptions } from './helpers' + +const storageConfig: AskarWalletPostgresStorageConfig = { + type: 'postgres', + config: { + host: 'localhost:5432', + }, + credentials: { + account: 'postgres', + password: 'postgres', + }, +} + +const alicePostgresAgentOptions = getPostgresAgentOptions('AgentsAlice', storageConfig, { + endpoints: ['rxjs:alice'], +}) +const bobPostgresAgentOptions = getPostgresAgentOptions('AgentsBob', storageConfig, { + endpoints: ['rxjs:bob'], +}) + +// FIXME: Re-include in tests when Askar NodeJS wrapper performance is improved +describe.skip('Askar Postgres agents', () => { + let aliceAgent: Agent + let bobAgent: Agent + let aliceConnection: ConnectionRecord + let bobConnection: ConnectionRecord + + afterAll(async () => { + if (bobAgent) { + await bobAgent.shutdown() + await bobAgent.wallet.delete() + } + + if (aliceAgent) { + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + } + }) + + test('make a connection between postgres agents', async () => { + const aliceMessages = new Subject() + const bobMessages = new Subject() + + const subjectMap = { + 'rxjs:alice': aliceMessages, + 'rxjs:bob': bobMessages, + } + + aliceAgent = new Agent(alicePostgresAgentOptions) + aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages)) + aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await aliceAgent.initialize() + + bobAgent = new Agent(bobPostgresAgentOptions) + bobAgent.registerInboundTransport(new SubjectInboundTransport(bobMessages)) + bobAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await bobAgent.initialize() + + const aliceBobOutOfBandRecord = await aliceAgent.oob.createInvitation({ + handshakeProtocols: [HandshakeProtocol.Connections], + }) + + const { connectionRecord: bobConnectionAtBobAlice } = await bobAgent.oob.receiveInvitation( + aliceBobOutOfBandRecord.outOfBandInvitation + ) + bobConnection = await bobAgent.connections.returnWhenIsConnected(bobConnectionAtBobAlice!.id) + + const [aliceConnectionAtAliceBob] = await aliceAgent.connections.findAllByOutOfBandId(aliceBobOutOfBandRecord.id) + aliceConnection = await aliceAgent.connections.returnWhenIsConnected(aliceConnectionAtAliceBob!.id) + }) + + test('send a message to connection', async () => { + const message = 'hello, world' + await aliceAgent.basicMessages.sendMessage(aliceConnection.id, message) + + const basicMessage = await waitForBasicMessage(bobAgent, { + content: message, + }) + + expect(basicMessage.content).toBe(message) + }) + + test('can shutdown and re-initialize the same postgres agent', async () => { + expect(aliceAgent.isInitialized).toBe(true) + await aliceAgent.shutdown() + expect(aliceAgent.isInitialized).toBe(false) + await aliceAgent.initialize() + expect(aliceAgent.isInitialized).toBe(true) + }) +}) diff --git a/packages/askar/tests/helpers.ts b/packages/askar/tests/helpers.ts new file mode 100644 index 0000000000..17a521a1af --- /dev/null +++ b/packages/askar/tests/helpers.ts @@ -0,0 +1,49 @@ +import type { AskarWalletPostgresStorageConfig } from '../src/wallet' +import type { InitConfig } from '@aries-framework/core' + +import { LogLevel } from '@aries-framework/core' +import path from 'path' + +import { TestLogger } from '../../core/tests/logger' +import { agentDependencies } from '../../node/src' +import { AskarModule } from '../src/AskarModule' + +export const genesisPath = process.env.GENESIS_TXN_PATH + ? path.resolve(process.env.GENESIS_TXN_PATH) + : path.join(__dirname, '../../../../network/genesis/local-genesis.txn') + +export const publicDidSeed = process.env.TEST_AGENT_PUBLIC_DID_SEED ?? '000000000000000000000000Trustee9' + +export function getPostgresAgentOptions( + name: string, + storageConfig: AskarWalletPostgresStorageConfig, + extraConfig: Partial = {} +) { + const config: InitConfig = { + label: `Agent: ${name}`, + walletConfig: { + id: `Wallet${name}`, + key: `Key${name}`, + storage: storageConfig, + }, + connectToIndyLedgersOnStartup: false, + publicDidSeed, + autoAcceptConnections: true, + autoUpdateStorageOnStartup: false, + indyLedgers: [ + { + id: `pool-${name}`, + indyNamespace: `pool:localtest`, + isProduction: false, + genesisPath, + }, + ], + logger: new TestLogger(LogLevel.off, name), + ...extraConfig, + } + return { + config, + dependencies: agentDependencies, + modules: { askar: new AskarModule() }, + } as const +} diff --git a/packages/askar/tests/setup.ts b/packages/askar/tests/setup.ts new file mode 100644 index 0000000000..a09e05318c --- /dev/null +++ b/packages/askar/tests/setup.ts @@ -0,0 +1,11 @@ +import 'reflect-metadata' + +try { + // eslint-disable-next-line import/no-extraneous-dependencies + require('@hyperledger/aries-askar-nodejs') +} catch (error) { + throw new Error('Could not load aries-askar bindings') +} + +// FIXME: Remove when Askar JS Wrapper performance issues are solved +jest.setTimeout(180000) diff --git a/packages/askar/tsconfig.build.json b/packages/askar/tsconfig.build.json new file mode 100644 index 0000000000..2b75d0adab --- /dev/null +++ b/packages/askar/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["src/**/*"] +} diff --git a/packages/askar/tsconfig.json b/packages/askar/tsconfig.json new file mode 100644 index 0000000000..46efe6f721 --- /dev/null +++ b/packages/askar/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + } +} diff --git a/packages/core/src/agent/Agent.ts b/packages/core/src/agent/Agent.ts index 2909c3536d..3785d00f4a 100644 --- a/packages/core/src/agent/Agent.ts +++ b/packages/core/src/agent/Agent.ts @@ -74,6 +74,9 @@ export class Agent extends BaseAge dependencyManager.registerInstance(InjectionSymbols.Stop$, new Subject()) dependencyManager.registerInstance(InjectionSymbols.FileSystem, new agentConfig.agentDependencies.FileSystem()) + // Register all modules. This will also include the default modules + dependencyManager.registerModules(modulesWithDefaultModules) + // Register possibly already defined services if (!dependencyManager.isRegistered(InjectionSymbols.Wallet)) { dependencyManager.registerContextScoped(InjectionSymbols.Wallet, IndyWallet) @@ -88,9 +91,6 @@ export class Agent extends BaseAge dependencyManager.registerSingleton(InjectionSymbols.MessageRepository, InMemoryMessageRepository) } - // Register all modules. This will also include the default modules - dependencyManager.registerModules(modulesWithDefaultModules) - // TODO: contextCorrelationId for base wallet // Bind the default agent context to the container for use in modules etc. dependencyManager.registerInstance( diff --git a/packages/core/src/storage/FileSystem.ts b/packages/core/src/storage/FileSystem.ts index 6673bc333c..b724e68158 100644 --- a/packages/core/src/storage/FileSystem.ts +++ b/packages/core/src/storage/FileSystem.ts @@ -2,6 +2,7 @@ export interface FileSystem { readonly basePath: string exists(path: string): Promise + createDirectory(path: string): Promise write(path: string, data: string): Promise read(path: string): Promise downloadToFile(url: string, path: string): Promise diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d2f5a21c8f..b454c7963e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -13,14 +13,16 @@ export enum KeyDerivationMethod { Raw = 'RAW', } +export interface WalletStorageConfig { + type: string + [key: string]: unknown +} + export interface WalletConfig { id: string key: string keyDerivationMethod?: KeyDerivationMethod - storage?: { - type: string - [key: string]: unknown - } + storage?: WalletStorageConfig masterSecretId?: string } diff --git a/packages/core/src/utils/TypedArrayEncoder.ts b/packages/core/src/utils/TypedArrayEncoder.ts index 685eac485c..83ee5d89ca 100644 --- a/packages/core/src/utils/TypedArrayEncoder.ts +++ b/packages/core/src/utils/TypedArrayEncoder.ts @@ -17,7 +17,7 @@ export class TypedArrayEncoder { * * @param buffer the buffer to encode into base64url string */ - public static toBase64URL(buffer: Buffer) { + public static toBase64URL(buffer: Buffer | Uint8Array) { return base64ToBase64URL(TypedArrayEncoder.toBase64(buffer)) } diff --git a/packages/node/src/NodeFileSystem.ts b/packages/node/src/NodeFileSystem.ts index f739c40814..240440d64c 100644 --- a/packages/node/src/NodeFileSystem.ts +++ b/packages/node/src/NodeFileSystem.ts @@ -29,6 +29,10 @@ export class NodeFileSystem implements FileSystem { } } + public async createDirectory(path: string): Promise { + await promises.mkdir(dirname(path), { recursive: true }) + } + public async write(path: string, data: string): Promise { // Make sure parent directories exist await promises.mkdir(dirname(path), { recursive: true }) diff --git a/packages/react-native/src/ReactNativeFileSystem.ts b/packages/react-native/src/ReactNativeFileSystem.ts index 331fa11a54..0eaab55429 100644 --- a/packages/react-native/src/ReactNativeFileSystem.ts +++ b/packages/react-native/src/ReactNativeFileSystem.ts @@ -21,6 +21,10 @@ export class ReactNativeFileSystem implements FileSystem { return RNFS.exists(path) } + public async createDirectory(path: string): Promise { + await RNFS.mkdir(getDirFromFilePath(path)) + } + public async write(path: string, data: string): Promise { // Make sure parent directories exist await RNFS.mkdir(getDirFromFilePath(path)) diff --git a/tests/e2e-askar-indy-sdk-wallet-subject.test.ts b/tests/e2e-askar-indy-sdk-wallet-subject.test.ts new file mode 100644 index 0000000000..b7d4233738 --- /dev/null +++ b/tests/e2e-askar-indy-sdk-wallet-subject.test.ts @@ -0,0 +1,135 @@ +import type { SubjectMessage } from './transport/SubjectInboundTransport' + +import { Subject } from 'rxjs' + +import { getAgentOptions, makeConnection, waitForBasicMessage } from '../packages/core/tests/helpers' + +import { AskarModule } from '@aries-framework/askar' +import { Agent, DependencyManager, InjectionSymbols } from '@aries-framework/core' +import { IndySdkModule, IndySdkStorageService, IndySdkWallet } from '@aries-framework/indy-sdk' + +import { SubjectInboundTransport } from './transport/SubjectInboundTransport' + +import { agentDependencies } from '@aries-framework/node' + +import { SubjectOutboundTransport } from './transport/SubjectOutboundTransport' + +// FIXME: Re-include in tests when Askar NodeJS wrapper performance is improved +describe.skip('E2E Askar-Indy SDK Wallet Subject tests', () => { + let recipientAgent: Agent + let senderAgent: Agent + + afterEach(async () => { + if (recipientAgent) { + await recipientAgent.shutdown() + await recipientAgent.wallet.delete() + } + + if (senderAgent) { + await senderAgent.shutdown() + await senderAgent.wallet.delete() + } + }) + + test('Wallet Subject flow - Indy Sender / Askar Receiver ', async () => { + // Sender is an Agent using Indy SDK Wallet + const senderDependencyManager = new DependencyManager() + senderDependencyManager.registerContextScoped(InjectionSymbols.Wallet, IndySdkWallet) + senderDependencyManager.registerSingleton(InjectionSymbols.StorageService, IndySdkStorageService) + senderAgent = new Agent( + { + ...getAgentOptions('E2E Wallet Subject Sender Indy', { endpoints: ['rxjs:sender'] }), + modules: { indySdk: new IndySdkModule({ indySdk: agentDependencies.indy }) }, + }, + senderDependencyManager + ) + + // Recipient is an Agent using Askar Wallet + recipientAgent = new Agent({ + ...getAgentOptions('E2E Wallet Subject Recipient Askar', { endpoints: ['rxjs:recipient'] }), + modules: { askar: new AskarModule() }, + }) + + await e2eWalletTest(senderAgent, recipientAgent) + }) + + test('Wallet Subject flow - Askar Sender / Askar Recipient ', async () => { + // Sender is an Agent using Askar Wallet + senderAgent = new Agent({ + ...getAgentOptions('E2E Wallet Subject Sender Askar', { endpoints: ['rxjs:sender'] }), + modules: { askar: new AskarModule() }, + }) + + // Recipient is an Agent using Askar Wallet + recipientAgent = new Agent({ + ...getAgentOptions('E2E Wallet Subject Recipient Askar', { endpoints: ['rxjs:recipient'] }), + modules: { askar: new AskarModule() }, + }) + + await e2eWalletTest(senderAgent, recipientAgent) + }) + + test('Wallet Subject flow - Indy Sender / Indy Recipient ', async () => { + // Sender is an Agent using Indy SDK Wallet + const senderDependencyManager = new DependencyManager() + senderDependencyManager.registerContextScoped(InjectionSymbols.Wallet, IndySdkWallet) + senderDependencyManager.registerSingleton(InjectionSymbols.StorageService, IndySdkStorageService) + senderAgent = new Agent( + { + ...getAgentOptions('E2E Wallet Subject Sender Indy', { endpoints: ['rxjs:sender'] }), + modules: { indySdk: new IndySdkModule({ indySdk: agentDependencies.indy }) }, + }, + senderDependencyManager + ) + + // Recipient is an Agent using Indy Wallet + const recipientDependencyManager = new DependencyManager() + recipientDependencyManager.registerContextScoped(InjectionSymbols.Wallet, IndySdkWallet) + recipientDependencyManager.registerSingleton(InjectionSymbols.StorageService, IndySdkStorageService) + recipientAgent = new Agent( + { + ...getAgentOptions('E2E Wallet Subject Recipient Indy', { endpoints: ['rxjs:recipient'] }), + modules: { indySdk: new IndySdkModule({ indySdk: agentDependencies.indy }) }, + }, + recipientDependencyManager + ) + + await e2eWalletTest(senderAgent, recipientAgent) + }) +}) + +export async function e2eWalletTest(senderAgent: Agent, recipientAgent: Agent) { + const recipientMessages = new Subject() + const senderMessages = new Subject() + + const subjectMap = { + 'rxjs:recipient': recipientMessages, + 'rxjs:sender': senderMessages, + } + + // Recipient Setup + recipientAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + recipientAgent.registerInboundTransport(new SubjectInboundTransport(recipientMessages)) + await recipientAgent.initialize() + + // Sender Setup + senderAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + senderAgent.registerInboundTransport(new SubjectInboundTransport(senderMessages)) + await senderAgent.initialize() + + // Make connection between sender and recipient + const [recipientSenderConnection, senderRecipientConnection] = await makeConnection(recipientAgent, senderAgent) + expect(recipientSenderConnection).toBeConnectedWith(senderRecipientConnection) + + // Sender sends a basic message and Recipient waits for it + await senderAgent.basicMessages.sendMessage(senderRecipientConnection.id, 'Hello') + await waitForBasicMessage(recipientAgent, { + content: 'Hello', + }) + + // Recipient sends a basic message and Sender waits for it + await recipientAgent.basicMessages.sendMessage(recipientSenderConnection.id, 'How are you?') + await waitForBasicMessage(senderAgent, { + content: 'How are you?', + }) +} diff --git a/yarn.lock b/yarn.lock index 03d3d0141d..0d5f1765f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -877,6 +877,26 @@ resolved "https://registry.yarnpkg.com/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz#98c23c950a3d9b6c8f0daed06da6c3af06981340" integrity sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q== +"@hyperledger/aries-askar-nodejs@^0.1.0-dev.1": + version "0.1.0-dev.1" + resolved "https://registry.yarnpkg.com/@hyperledger/aries-askar-nodejs/-/aries-askar-nodejs-0.1.0-dev.1.tgz#b384d422de48f0ce5918e1612d2ca32ebd160520" + integrity sha512-XrRskQ0PaNAerItvfxKkS8YaVg+iuImguoqfyQ4ZSaePKZQnTqZpkxo6faKS+GlsaubRXz/6yz3YndVRIxPO+w== + dependencies: + "@hyperledger/aries-askar-shared" "0.1.0-dev.1" + "@mapbox/node-pre-gyp" "^1.0.10" + ffi-napi "^4.0.3" + node-cache "^5.1.2" + ref-array-di "^1.2.2" + ref-napi "^3.0.3" + ref-struct-di "^1.1.1" + +"@hyperledger/aries-askar-shared@0.1.0-dev.1", "@hyperledger/aries-askar-shared@^0.1.0-dev.1": + version "0.1.0-dev.1" + resolved "https://registry.yarnpkg.com/@hyperledger/aries-askar-shared/-/aries-askar-shared-0.1.0-dev.1.tgz#4e4e494c3a44c7c82f7b95ad4f06149f2a3a9b6c" + integrity sha512-Pt525M6CvnE3N6jxMpSqLy7RpOsc4oqa2Q+hc2UdCHuSYwmM/aeqt6wiA5dpghvl8g/78lCi1Dz74pzp7Dmm3w== + dependencies: + fast-text-encoding "^1.0.3" + "@hyperledger/indy-vdr-nodejs@^0.1.0-dev.4": version "0.1.0-dev.11" resolved "https://registry.yarnpkg.com/@hyperledger/indy-vdr-nodejs/-/indy-vdr-nodejs-0.1.0-dev.11.tgz#376f16651aa03f0d9feb98880210e41c0e8dcb9a" @@ -3953,6 +3973,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clone@2.x: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== + clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -8442,6 +8467,13 @@ node-addon-api@^3.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== +node-cache@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== + dependencies: + clone "2.x" + node-dir@^0.1.17: version "0.1.17" resolved "https://registry.yarnpkg.com/node-dir/-/node-dir-0.1.17.tgz#5f5665d93351335caabef8f1c554516cf5f1e4e5"