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 @@
+
+
+
+
+Aries Framework JavaScript Askar Module
+
+
+
+
+
+
+
+
+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"