From c89f9445b4ef375f32d2958ded2442a368ea6a28 Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:16:13 +0100 Subject: [PATCH 01/12] Password-protected RelationshipTemplates (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add password protection to templates * test: add tests * fix/test: add tests, make fixes * fix: backbone API * feat: change error message * refactor: nameof, toString * feat/test: error message, add validation test * fix: remove only * chore: bump backbone Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * fix: missing password pass * refactor: wrong variable name * refactor: review comments * chore: upgrade backbone and adapt client * feat: hash passwords * feat: add separate pin * feat: enhance password type * refactor: align error messages * fix: more enhancing password type * test: adapt tests * test: reference adaptations * wip * refactor/feat: review comments * feat: add transport empty string validation * test: add tests * fix: schemas, error codes * fix: add PINs when loading * feat: add loading validation, tests * test: add validations * fix: test errors * fix/feat: add loading validation, fix tests * test: fix tests * refactor: no PIN validation in loading schema * test: fix copy-paste error * refactor: use schemas for empty string validation * feat: use salt * refactor/test: reference adaptations * feat: adapt automatic version setting * fix: version in reference * feat: remove salt from dto * test: refactor tests * feat: add/adapt salt validation * refactor/test: validations * test: fix ids * chore: bump backbone * feat: remove version * test: fix salt test * chore: transport PR comments * chore: runtime PR comments * fix/refactor: more stuff * test: correct check * test: fix used function * feat: add transport setting validation * refactor: import * chore: build schemas * refactor: passwordinfo * fix: cleanup * test: cleanup * refactor/fix: use password info derivatives * refactor: remove unused error * test: fix error names in tests * feat: password error message * refactor: test names and content, class usage * feat: runtime interface with flag * test: adapt tests * chore: schemaas * fix: mapping, tests * refactor: simplify object access * refactor: rename passwordProtection * refactor: naming * chore: move business logic to object * refactor: use min * fix: tests * feat: passwordIsPin true or undefined * chore: build schemas * chore: remove unused method * test: fix tests * fix: ability to truncate * refactor: add fromTruncted * feat: error message mentions wrong password * test: add passwordType check * test: fix error code, naming * test: reorder, naming * test: fix error * fix: check for password * refactor: no error altering --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Julian König --- .dev/compose.backbone.env | 2 +- packages/consumption/test/core/TestUtil.ts | 7 +- .../modules/requests/RequestEnd2End.test.ts | 6 +- .../PeerRelationshipTemplateDVO.ts | 4 + .../transport/RelationshipTemplateDVO.ts | 4 + .../transport/RelationshipTemplateDTO.ts | 4 + .../src/useCases/common/RuntimeErrors.ts | 4 + .../runtime/src/useCases/common/Schemas.ts | 20 ++ .../account/LoadItemFromTruncatedReference.ts | 2 +- .../CreateOwnRelationshipTemplate.ts | 18 +- .../CreateTokenForOwnRelationshipTemplate.ts | 14 +- ...teTokenQRCodeForOwnRelationshipTemplate.ts | 7 +- .../LoadPeerRelationshipTemplate.ts | 17 +- .../RelationshipTemplateMapper.ts | 6 + .../dataViews/RelationshipTemplateDVO.test.ts | 26 ++- .../transport/relationshipTemplates.test.ts | 187 ++++++++++++++++++ packages/transport/src/core/Reference.ts | 28 ++- .../transport/src/core/TransportCoreErrors.ts | 4 + .../src/core/types/PasswordProtection.ts | 34 ++++ .../PasswordProtectionCreationParameters.ts | 29 +++ .../core/types/SharedPasswordProtection.ts | 43 ++++ packages/transport/src/core/types/index.ts | 3 + .../RelationshipTemplateController.ts | 117 +++++++++-- .../BackboneGetRelationshipTemplates.ts | 2 +- .../BackbonePostRelationshipTemplates.ts | 1 + .../backbone/RelationshipTemplateClient.ts | 5 +- .../local/RelationshipTemplate.ts | 16 +- .../SendRelationshipTemplateParameters.ts | 6 + .../TokenContentRelationshipTemplate.ts | 6 + .../transport/test/end2end/End2End.test.ts | 13 +- .../test/modules/files/FileReference.test.ts | 95 +++++++-- .../RelationshipTemplateController.test.ts | 90 ++++++++- .../RelationshipTemplateReference.test.ts | 95 +++++++-- .../modules/sync/SyncController.error.test.ts | 6 +- .../sync/SyncController.relationships.test.ts | 18 +- .../test/modules/tokens/TokenContent.test.ts | 96 ++++++++- .../modules/tokens/TokenController.test.ts | 159 +++++++++++---- .../modules/tokens/TokenReference.test.ts | 95 +++++++-- .../transport/test/testHelpers/TestUtil.ts | 8 +- 39 files changed, 1090 insertions(+), 207 deletions(-) create mode 100644 packages/transport/src/core/types/PasswordProtection.ts create mode 100644 packages/transport/src/core/types/PasswordProtectionCreationParameters.ts create mode 100644 packages/transport/src/core/types/SharedPasswordProtection.ts diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index 903a8dc09..8115afe54 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.13.2 +BACKBONE_VERSION=6.15.2 diff --git a/packages/consumption/test/core/TestUtil.ts b/packages/consumption/test/core/TestUtil.ts index d1b30f8dc..d5ef334f8 100644 --- a/packages/consumption/test/core/TestUtil.ts +++ b/packages/consumption/test/core/TestUtil.ts @@ -187,7 +187,8 @@ export class TestUtil { maxNumberOfAllocations: 1 }); - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(templateFrom.id, templateFrom.secretKey); + const reference = templateFrom.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await to.relationships.sendRelationship({ template: templateTo, @@ -241,7 +242,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); const relRequest = await to.relationships.sendRelationship({ template: templateTo, @@ -432,7 +433,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const template = await account.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const template = await account.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); return template; } diff --git a/packages/consumption/test/modules/requests/RequestEnd2End.test.ts b/packages/consumption/test/modules/requests/RequestEnd2End.test.ts index 9f7036446..9297f5105 100644 --- a/packages/consumption/test/modules/requests/RequestEnd2End.test.ts +++ b/packages/consumption/test/modules/requests/RequestEnd2End.test.ts @@ -56,7 +56,8 @@ describe("End2End Request/Response via Relationship Template", function () { }); test("recipient: load Relationship Template", async function () { - rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplate(sTemplate.id, sTemplate.secretKey); + const reference = sTemplate.toRelationshipTemplateReference().truncate(); + rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); }); test("recipient: create Local Request", async function () { @@ -343,7 +344,8 @@ describe("End2End Request via Template and Response via Message", function () { }); test("recipient: load Relationship Template", async function () { - rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplate(sTemplate.id, sTemplate.secretKey); + const reference = sTemplate.toRelationshipTemplateReference().truncate(); + rTemplate = await rAccountController.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); }); test("recipient: create Local Request", async function () { diff --git a/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts b/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts index 39c9ef89c..bfc1e888f 100644 --- a/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts +++ b/packages/runtime/src/dataViews/consumption/PeerRelationshipTemplateDVO.ts @@ -12,6 +12,10 @@ export interface PeerRelationshipTemplateDVO extends DataViewObject { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; /** * Is optional, as there can be RelationshipTemplates without actual requests in it diff --git a/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts b/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts index 1b77c3021..18bc493dc 100644 --- a/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts +++ b/packages/runtime/src/dataViews/transport/RelationshipTemplateDVO.ts @@ -13,6 +13,10 @@ export interface RelationshipTemplateDVO extends DataViewObject { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; /** * Is optional, as there can be RelationshipTemplates without actual requests in it diff --git a/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts b/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts index be249527e..4952186a8 100644 --- a/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts +++ b/packages/runtime/src/types/transport/RelationshipTemplateDTO.ts @@ -9,6 +9,10 @@ export interface RelationshipTemplateDTO { createdByDevice: string; createdAt: string; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; content: RelationshipTemplateContentDerivation; expiresAt?: string; maxNumberOfAllocations?: number; diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index ba6d19e27..7fc75add3 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -51,6 +51,10 @@ class General { public cacheEmpty(entityName: string | Function, id: string) { return new ApplicationError("error.runtime.cacheEmpty", `The cache of ${entityName instanceof Function ? entityName.name : entityName} with id '${id}' is empty.`); } + + public invalidPin(): ApplicationError { + return new ApplicationError("error.runtime.validation.invalidPin", "The PIN is invalid. It must consist of 4 to 16 digits from 0 to 9."); + } } class Serval { diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 9951b600a..87e5c1967 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -22017,6 +22017,23 @@ export const CreateOwnRelationshipTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22301,6 +22318,9 @@ export const LoadPeerRelationshipTemplateRequest: any = { "$ref": "#/definitions/RelationshipTemplateReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ diff --git a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts index 5ec7fac11..87b9ef427 100644 --- a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts +++ b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts @@ -93,7 +93,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase { @@ -39,6 +46,12 @@ class Validator extends SchemaValidator { ); } + if (input.passwordProtection?.passwordIsPin) { + if (!/^[0-9]{4,16}$/.test(input.passwordProtection.password)) { + validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPin())); + } + } + return validationResult; } } @@ -67,7 +80,8 @@ export class CreateOwnRelationshipTemplateUseCase extends UseCase { @@ -29,31 +30,31 @@ export class LoadPeerRelationshipTemplateUseCase extends UseCase> { - const result = await this.loadRelationshipTemplateFromReference(request.reference); + const result = await this.loadRelationshipTemplateFromReference(request.reference, request.password); await this.accountController.syncDatawallet(); return result; } - private async loadRelationshipTemplateFromReference(reference: string): Promise> { + private async loadRelationshipTemplateFromReference(reference: string, password?: string): Promise> { if (reference.startsWith(Base64ForIdPrefix.RelationshipTemplate)) { - return await this.loadRelationshipTemplateFromRelationshipTemplateReference(reference); + return await this.loadRelationshipTemplateFromRelationshipTemplateReference(reference, password); } if (reference.startsWith(Base64ForIdPrefix.Token)) { - return await this.loadRelationshipTemplateFromTokenReference(reference); + return await this.loadRelationshipTemplateFromTokenReference(reference, password); } throw RuntimeErrors.relationshipTemplates.invalidReference(reference); } - private async loadRelationshipTemplateFromRelationshipTemplateReference(relationshipTemplateReference: string) { - const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(relationshipTemplateReference); + private async loadRelationshipTemplateFromRelationshipTemplateReference(relationshipTemplateReference: string, password?: string): Promise> { + const template = await this.templateController.loadPeerRelationshipTemplateByTruncated(relationshipTemplateReference, password); return Result.ok(RelationshipTemplateMapper.toRelationshipTemplateDTO(template)); } - private async loadRelationshipTemplateFromTokenReference(tokenReference: string): Promise> { + private async loadRelationshipTemplateFromTokenReference(tokenReference: string, password?: string): Promise> { const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); if (!token.cache) { @@ -65,7 +66,7 @@ export class LoadPeerRelationshipTemplateUseCase extends UseCase { maxNumberOfAllocations: 1, expiresAt: DateTime.utc().plus({ minutes: 10 }).toString(), content: templateContent, - forIdentity: requestor.address + forIdentity: requestor.address, + passwordProtection: { + password: "password" + } }) ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; templateId = templatorTemplate.id; @@ -168,6 +171,8 @@ describe("RelationshipTemplateDVO", () => { expect(dvo.isOwn).toBe(true); expect(dvo.maxNumberOfAllocations).toBe(1); expect(dvo.forIdentity).toBe(requestor.address); + expect(dvo.passwordProtection!.password).toBe("password"); + expect(dvo.passwordProtection!.passwordIsPin).toBeUndefined(); expect(dvo.onNewRelationship!.type).toBe("RequestDVO"); expect(dvo.onNewRelationship!.items).toHaveLength(2); @@ -186,8 +191,13 @@ describe("RelationshipTemplateDVO", () => { }); test("TemplateDVO for requestor", async () => { - const requestorTemplate = (await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ reference: templatorTemplate.truncatedReference })) - .value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + const requestorTemplate = ( + await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: templatorTemplate.truncatedReference, + password: "password" + }) + ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + await requestor.eventBus.waitForEvent(IncomingRequestStatusChangedEvent, (e) => e.data.newStatus === LocalRequestStatus.DecisionRequired); const dto = requestorTemplate; @@ -203,6 +213,8 @@ describe("RelationshipTemplateDVO", () => { expect(dvo.isOwn).toBe(false); expect(dvo.maxNumberOfAllocations).toBe(1); expect(dvo.forIdentity).toBe(requestor.address); + expect(dvo.passwordProtection!.password).toBe("password"); + expect(dvo.passwordProtection!.passwordIsPin).toBeUndefined(); expect(dvo.onNewRelationship!.type).toBe("RequestDVO"); expect(dvo.onNewRelationship!.items).toHaveLength(2); @@ -260,8 +272,12 @@ describe("RelationshipTemplateDVO", () => { "source.reference": templateId } }); - const requestorTemplate = (await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ reference: templatorTemplate.truncatedReference })) - .value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; + const requestorTemplate = ( + await requestor.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: templatorTemplate.truncatedReference, + password: "password" + }) + ).value as RelationshipTemplateDTO & { content: RelationshipTemplateContentJSON }; if (requestResult.value.length === 0) { await requestor.eventBus.waitForEvent(IncomingRequestStatusChangedEvent, (e) => e.data.newStatus === LocalRequestStatus.DecisionRequired); requestResult = await requestor.consumption.incomingRequests.getRequests({ diff --git a/packages/runtime/test/transport/relationshipTemplates.test.ts b/packages/runtime/test/transport/relationshipTemplates.test.ts index d46560234..6d5ee8fb2 100644 --- a/packages/runtime/test/transport/relationshipTemplates.test.ts +++ b/packages/runtime/test/transport/relationshipTemplates.test.ts @@ -1,4 +1,5 @@ import { RelationshipTemplateContent, RelationshipTemplateContentJSON } from "@nmshd/content"; +import { RelationshipTemplateReference } from "@nmshd/transport"; import { DateTime } from "luxon"; import { GetRelationshipTemplatesQuery, OwnerRestriction } from "../../src"; import { emptyRelationshipTemplateContent, QueryParamConditions, RuntimeServiceProvider, TestRuntimeServices } from "../lib"; @@ -247,6 +248,192 @@ describe("RelationshipTemplate Tests", () => { expect(createQRCodeWithoutPersonalizationResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.personalizationMustBeInherited"); }); }); + + describe("Password-protected templates", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("password"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("1234"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("send and receive a password-protected template via a token", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template via a token", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }) + ).value.id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const templateId = ( + await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }) + ).value.id; + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a template with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "" + } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a template with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "invalid-pin", + passwordIsPin: true + } + }); + expect(createResult).toBeAnError(/.*/, "error.runtime.validation.invalidPin"); + }); + }); }); describe("Serialization Errors", () => { diff --git a/packages/transport/src/core/Reference.ts b/packages/transport/src/core/Reference.ts index 8b3a38bef..807f89f7d 100644 --- a/packages/transport/src/core/Reference.ts +++ b/packages/transport/src/core/Reference.ts @@ -1,17 +1,19 @@ -import { ISerializable, Serializable, serialize, validate, ValidationError } from "@js-soft/ts-serval"; +import { ISerializable, Serializable, serialize, type, validate, ValidationError } from "@js-soft/ts-serval"; import { CoreId, ICoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { CoreIdHelper } from "./CoreIdHelper"; import { TransportCoreErrors } from "./TransportCoreErrors"; +import { ISharedPasswordProtection, SharedPasswordProtection } from "./types"; export interface IReference extends ISerializable { id: ICoreId; backboneBaseUrl?: string; key: ICryptoSecretKey; forIdentityTruncated?: string; - passwordType?: number; + passwordProtection?: ISharedPasswordProtection; } +@type("Reference") export class Reference extends Serializable implements IReference { @validate({ regExp: new RegExp("^[A-Za-z0-9]{20}$") }) @serialize() @@ -29,14 +31,15 @@ export class Reference extends Serializable implements IReference { @serialize() public forIdentityTruncated?: string; - @validate({ nullable: true, min: 1, max: 12, customValidator: (v) => (!Number.isInteger(v) ? "must be an integer" : undefined) }) + @validate({ nullable: true }) @serialize() - public passwordType?: number; + public passwordProtection?: SharedPasswordProtection; public truncate(): string { const idPart = this.backboneBaseUrl ? `${this.id.toString()}@${this.backboneBaseUrl}` : this.id.toString(); + const truncatedReference = CoreBuffer.fromUtf8( - `${idPart}|${this.key.algorithm}|${this.key.secretKey.toBase64URL()}|${this.forIdentityTruncated ? this.forIdentityTruncated : ""}|${this.passwordType ? this.passwordType.toString() : ""}` + `${idPart}|${this.key.algorithm}|${this.key.secretKey.toBase64URL()}|${this.forIdentityTruncated ?? ""}|${this.passwordProtection?.truncate() ?? ""}` ); return truncatedReference.toBase64URL(); } @@ -54,27 +57,18 @@ export class Reference extends Serializable implements IReference { const secretKey = this.parseSecretKey(splitted[1], splitted[2]); const forIdentityTruncated = splitted[3] ? splitted[3] : undefined; - const passwordType = splitted[4] ? this.parsePasswordType(splitted[4]) : undefined; + + const passwordProtection = SharedPasswordProtection.fromTruncated(splitted[4]); return this.from({ id: CoreId.from(id), backboneBaseUrl, key: secretKey, forIdentityTruncated, - passwordType + passwordProtection }); } - private static parsePasswordType(value: string): number | undefined { - try { - if (value === "") return undefined; - - return parseInt(value); - } catch (_) { - throw TransportCoreErrors.general.invalidTruncatedReference("The password type must be indicated by an integer in the TruncatedReference."); - } - } - private static parseSecretKey(alg: string, secretKey: string): CryptoSecretKey { let algorithm: number; diff --git a/packages/transport/src/core/TransportCoreErrors.ts b/packages/transport/src/core/TransportCoreErrors.ts index e866bb1e1..8daba3013 100644 --- a/packages/transport/src/core/TransportCoreErrors.ts +++ b/packages/transport/src/core/TransportCoreErrors.ts @@ -206,6 +206,10 @@ class General { public accountControllerInitialSyncFailed() { return new CoreError("error.transport.accountControllerInitialSyncFailed", "The initial sync of the AccountController failed."); } + + public noPasswordProvided() { + return new CoreError("error.transport.noPasswordProvided", "You need to provide a password to perform this operation."); + } } export class TransportCoreErrors { diff --git a/packages/transport/src/core/types/PasswordProtection.ts b/packages/transport/src/core/types/PasswordProtection.ts new file mode 100644 index 000000000..0f61087ff --- /dev/null +++ b/packages/transport/src/core/types/PasswordProtection.ts @@ -0,0 +1,34 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; +import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { SharedPasswordProtection } from "./SharedPasswordProtection"; + +export interface IPasswordProtection extends ISerializable { + passwordType: string; + salt: ICoreBuffer; + password: string; +} + +export class PasswordProtection extends Serializable implements IPasswordProtection { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ customValidator: (v: ICoreBuffer) => (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) + @serialize() + public salt: CoreBuffer; + + @validate({ min: 1 }) + @serialize() + public password: string; + + public static from(value: IPasswordProtection): PasswordProtection { + return this.fromAny(value); + } + + public toSharedPasswordProtection(): SharedPasswordProtection { + return SharedPasswordProtection.from({ + passwordType: this.passwordType, + salt: this.salt + }); + } +} diff --git a/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts new file mode 100644 index 000000000..5af7adc42 --- /dev/null +++ b/packages/transport/src/core/types/PasswordProtectionCreationParameters.ts @@ -0,0 +1,29 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; + +export interface IPasswordProtectionCreationParameters extends ISerializable { + passwordType: string; + password: string; +} + +export class PasswordProtectionCreationParameters extends Serializable implements IPasswordProtectionCreationParameters { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ min: 1 }) + @serialize() + public password: string; + + public static from(value: IPasswordProtectionCreationParameters): PasswordProtectionCreationParameters { + return this.fromAny(value); + } + + public static create(params: { password: string; passwordIsPin?: true } | undefined): PasswordProtectionCreationParameters | undefined { + if (!params) return; + + return PasswordProtectionCreationParameters.from({ + password: params.password, + passwordType: params.passwordIsPin ? `pin${params.password.length}` : "pw" + }); + } +} diff --git a/packages/transport/src/core/types/SharedPasswordProtection.ts b/packages/transport/src/core/types/SharedPasswordProtection.ts new file mode 100644 index 000000000..629a3eecd --- /dev/null +++ b/packages/transport/src/core/types/SharedPasswordProtection.ts @@ -0,0 +1,43 @@ +import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; +import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { TransportCoreErrors } from "../TransportCoreErrors"; + +export interface ISharedPasswordProtection extends ISerializable { + passwordType: string; + salt: ICoreBuffer; +} + +export class SharedPasswordProtection extends Serializable implements ISharedPasswordProtection { + @validate({ regExp: /^(pw|pin(4|5|6|7|8|9|10|11|12|13|14|15|16))$/ }) + @serialize() + public passwordType: string; + + @validate({ customValidator: (v: ICoreBuffer) => (v.buffer.byteLength === 16 ? undefined : "must be 16 bytes long") }) + @serialize() + public salt: CoreBuffer; + + public static from(value: ISharedPasswordProtection): SharedPasswordProtection { + return this.fromAny(value); + } + + public static fromTruncated(value?: string): SharedPasswordProtection | undefined { + if (value === undefined || value === "") return undefined; + + const splittedPasswordParts = value.split("&"); + if (splittedPasswordParts.length !== 2) { + throw TransportCoreErrors.general.invalidTruncatedReference("The password part of a TruncatedReference must consist of exactly 2 components."); + } + + const passwordType = splittedPasswordParts[0]; + try { + const salt = CoreBuffer.fromBase64(splittedPasswordParts[1]); + return SharedPasswordProtection.from({ passwordType, salt }); + } catch (_) { + throw TransportCoreErrors.general.invalidTruncatedReference("The salt needs to be a Base64 value."); + } + } + + public truncate(): string { + return `${this.passwordType}&${this.salt.toBase64()}`; + } +} diff --git a/packages/transport/src/core/types/index.ts b/packages/transport/src/core/types/index.ts index 34d5d158c..0f1973daf 100644 --- a/packages/transport/src/core/types/index.ts +++ b/packages/transport/src/core/types/index.ts @@ -1,2 +1,5 @@ export * from "./CoreHash"; +export * from "./PasswordProtection"; +export * from "./PasswordProtectionCreationParameters"; +export * from "./SharedPasswordProtection"; export * from "./TransportVersion"; diff --git a/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts b/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts index d4476e086..b9c52f1bf 100644 --- a/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts +++ b/packages/transport/src/modules/relationshipTemplates/RelationshipTemplateController.ts @@ -2,7 +2,7 @@ import { ISerializable } from "@js-soft/ts-serval"; import { log } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; -import { CoreCrypto, TransportCoreErrors } from "../../core"; +import { CoreCrypto, PasswordProtection, TransportCoreErrors } from "../../core"; import { DbCollectionName } from "../../core/DbCollectionName"; import { ControllerName, TransportController } from "../../core/TransportController"; import { PeerRelationshipTemplateLoadedEvent } from "../../events"; @@ -10,6 +10,7 @@ import { AccountController } from "../accounts/AccountController"; import { Relationship } from "../relationships/local/Relationship"; import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; +import { TokenContentRelationshipTemplate } from "../tokens/transmission/TokenContentRelationshipTemplate"; import { BackboneGetRelationshipTemplatesResponse } from "./backbone/BackboneGetRelationshipTemplates"; import { RelationshipTemplateClient } from "./backbone/RelationshipTemplateClient"; import { CachedRelationshipTemplate } from "./local/CachedRelationshipTemplate"; @@ -40,7 +41,6 @@ export class RelationshipTemplateController extends TransportController { public async sendRelationshipTemplate(parameters: ISendRelationshipTemplateParameters): Promise { parameters = SendRelationshipTemplateParameters.from(parameters); - const templateKey = await this.secrets.createTemplateKey(); const templateContent = RelationshipTemplateContentWrapper.from({ @@ -62,11 +62,16 @@ export class RelationshipTemplateController extends TransportController { const cipher = await CoreCrypto.encrypt(signedTemplateBuffer, secretKey); + const password = parameters.passwordProtection?.password; + const salt = password ? await CoreCrypto.random(16) : undefined; + const hashedPassword = password ? (await CoreCrypto.deriveHashOutOfPassword(password, salt!)).toBase64() : undefined; + const backboneResponse = ( await this.client.createRelationshipTemplate({ expiresAt: parameters.expiresAt.toString(), maxNumberOfAllocations: parameters.maxNumberOfAllocations, forIdentity: parameters.forIdentity?.address.toString(), + password: hashedPassword, content: cipher.toBase64() }) ).value; @@ -83,10 +88,20 @@ export class RelationshipTemplateController extends TransportController { templateKey: templateKey }); + const passwordProtection = parameters.passwordProtection + ? PasswordProtection.from({ + password: parameters.passwordProtection.password, + passwordType: parameters.passwordProtection.passwordType, + salt: salt! + }) + : undefined; + const template = RelationshipTemplate.from({ id: CoreId.from(backboneResponse.id), secretKey: secretKey, isOwn: true, + passwordProtection, + cache: templateCache, cachedAt: CoreDate.utc() }); @@ -113,7 +128,21 @@ export class RelationshipTemplateController extends TransportController { return []; } - const resultItems = (await this.client.getRelationshipTemplates({ ids })).value; + const templates = await this.readRelationshipTemplates(ids); + + const resultItems = ( + await this.client.getRelationshipTemplates({ + templates: await Promise.all( + templates.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value; + const promises = []; for await (const resultItem of resultItems) { promises.push(this.updateCacheOfExistingTemplateInDb(resultItem.id, resultItem)); @@ -124,25 +153,43 @@ export class RelationshipTemplateController extends TransportController { public async fetchCaches(ids: CoreId[]): Promise<{ id: CoreId; cache: CachedRelationshipTemplate }[]> { if (ids.length === 0) return []; + const templates = await this.readRelationshipTemplates(ids.map((id) => id.toString())); + + const backboneRelationshipTemplates = await ( + await this.client.getRelationshipTemplates({ + templates: await Promise.all( + templates.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value.collect(); + + const decryptionPromises = backboneRelationshipTemplates.map(async (t) => { + const template = templates.find((template) => template.id.toString() === t.id); + if (!template) return; + return { id: CoreId.from(t.id), cache: await this.decryptRelationshipTemplate(t, template.secretKey) }; + }); - const backboneRelationships = await (await this.client.getRelationshipTemplates({ ids: ids.map((id) => id.id) })).value.collect(); + const caches = await Promise.all(decryptionPromises); + return caches.filter((c) => c !== undefined); + } - const decryptionPromises = backboneRelationships.map(async (t) => { - const templateDoc = await this.templates.read(t.id); + private async readRelationshipTemplates(ids: string[]): Promise { + const templatePromises = ids.map(async (id) => { + const templateDoc = await this.templates.read(id); if (!templateDoc) { - this._log.error( - `Template '${t.id}' not found in local database and the cache fetching was therefore skipped. This should not happen and might be a bug in the application logic.` - ); + this._log.error(`Template '${id}' not found in local database. This should not happen and might be a bug in the application logic.`); return; } - const template = RelationshipTemplate.from(templateDoc); - - return { id: CoreId.from(t.id), cache: await this.decryptRelationshipTemplate(t, template.secretKey) }; + return RelationshipTemplate.from(templateDoc); }); - const caches = await Promise.all(decryptionPromises); - return caches.filter((c) => c !== undefined); + return (await Promise.all(templatePromises)).filter((t) => t !== undefined); } @log() @@ -161,7 +208,10 @@ export class RelationshipTemplateController extends TransportController { private async updateCacheOfTemplate(template: RelationshipTemplate, response?: BackboneGetRelationshipTemplatesResponse) { if (!response) { - response = (await this.client.getRelationshipTemplate(template.id.toString())).value; + const hashedPassword = template.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(template.passwordProtection.password, template.passwordProtection.salt)).toBase64() + : undefined; + response = (await this.client.getRelationshipTemplate(template.id.toString(), hashedPassword)).value; } const cachedTemplate = await this.decryptRelationshipTemplate(response, template.secretKey); @@ -227,12 +277,40 @@ export class RelationshipTemplateController extends TransportController { return template; } - public async loadPeerRelationshipTemplateByTruncated(truncated: string): Promise { + public async loadPeerRelationshipTemplateByTruncated(truncated: string, password?: string): Promise { const reference = RelationshipTemplateReference.fromTruncated(truncated); - return await this.loadPeerRelationshipTemplate(reference.id, reference.key, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerRelationshipTemplate(reference.id, reference.key, reference.forIdentityTruncated, passwordProtection); + } + + public async loadPeerRelationshipTemplateByTokenContent(tokenContent: TokenContentRelationshipTemplate, password?: string): Promise { + if (tokenContent.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = tokenContent.passwordProtection + ? PasswordProtection.from({ + salt: tokenContent.passwordProtection.salt, + passwordType: tokenContent.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerRelationshipTemplate(tokenContent.templateId, tokenContent.secretKey, tokenContent.forIdentity?.toString(), passwordProtection); } - public async loadPeerRelationshipTemplate(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string): Promise { + private async loadPeerRelationshipTemplate( + id: CoreId, + secretKey: CryptoSecretKey, + forIdentityTruncated?: string, + passwordProtection?: PasswordProtection + ): Promise { const templateDoc = await this.templates.read(id.toString()); if (!templateDoc && forIdentityTruncated && !this.parent.identity.address.toString().endsWith(forIdentityTruncated)) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); @@ -251,7 +329,8 @@ export class RelationshipTemplateController extends TransportController { const relationshipTemplate = RelationshipTemplate.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); await this.updateCacheOfTemplate(relationshipTemplate); diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts b/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts index a5d12ff98..784b67045 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/BackboneGetRelationshipTemplates.ts @@ -1,5 +1,5 @@ export interface BackboneGetRelationshipTemplatesRequest { - ids: string[]; + templates: { id: string; password?: string }[]; } export interface BackboneGetRelationshipTemplatesResponse { diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts b/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts index 63d683597..6fb10785d 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/BackbonePostRelationshipTemplates.ts @@ -2,6 +2,7 @@ export interface BackbonePostRelationshipTemplatesRequest { expiresAt?: string; maxNumberOfAllocations?: number; forIdentity?: string; + password?: string; content: string; } diff --git a/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts b/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts index 77f1f867a..8129199c8 100644 --- a/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts +++ b/packages/transport/src/modules/relationshipTemplates/backbone/RelationshipTemplateClient.ts @@ -9,8 +9,9 @@ export class RelationshipTemplateClient extends RESTClientAuthenticate { return await this.getPaged("/api/v1/RelationshipTemplates", request); } - public async getRelationshipTemplate(id: string): Promise> { - return await this.get(`/api/v1/RelationshipTemplates/${id}`); + public async getRelationshipTemplate(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/RelationshipTemplates/${id}`, request); } public async deleteRelationshipTemplate(id: string): Promise> { diff --git a/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts b/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts index b9b02e84f..1caa423ad 100644 --- a/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts +++ b/packages/transport/src/modules/relationshipTemplates/local/RelationshipTemplate.ts @@ -2,13 +2,14 @@ import { serialize, type, validate } from "@js-soft/ts-serval"; import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { nameof } from "ts-simple-nameof"; -import { CoreSynchronizable, ICoreSynchronizable } from "../../../core"; +import { CoreSynchronizable, ICoreSynchronizable, IPasswordProtection, PasswordProtection } from "../../../core"; import { RelationshipTemplateReference } from "../transmission/RelationshipTemplateReference"; import { CachedRelationshipTemplate, ICachedRelationshipTemplate } from "./CachedRelationshipTemplate"; export interface IRelationshipTemplate extends ICoreSynchronizable { secretKey: ICryptoSecretKey; isOwn: boolean; + passwordProtection?: IPasswordProtection; cache?: ICachedRelationshipTemplate; cachedAt?: ICoreDate; metadata?: any; @@ -18,7 +19,7 @@ export interface IRelationshipTemplate extends ICoreSynchronizable { @type("RelationshipTemplate") export class RelationshipTemplate extends CoreSynchronizable implements IRelationshipTemplate { public override readonly technicalProperties = ["@type", "@context", nameof((r) => r.secretKey), nameof((r) => r.isOwn)]; - + public override readonly userdataProperties = [nameof((r) => r.passwordProtection)]; public override readonly metadataProperties = [nameof((r) => r.metadata), nameof((r) => r.metadataModifiedAt)]; @validate() @@ -29,6 +30,10 @@ export class RelationshipTemplate extends CoreSynchronizable implements IRelatio @serialize() public isOwn: boolean; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtection; + @validate({ nullable: true }) @serialize() public cache?: CachedRelationshipTemplate; @@ -50,7 +55,12 @@ export class RelationshipTemplate extends CoreSynchronizable implements IRelatio } public toRelationshipTemplateReference(): RelationshipTemplateReference { - return RelationshipTemplateReference.from({ id: this.id, key: this.secretKey, forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4) }); + return RelationshipTemplateReference.from({ + id: this.id, + key: this.secretKey, + forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4), + passwordProtection: this.passwordProtection?.toSharedPasswordProtection() + }); } public truncate(): string { diff --git a/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts b/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts index 88ae0d7e5..66e9cce4a 100644 --- a/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts +++ b/packages/transport/src/modules/relationshipTemplates/local/SendRelationshipTemplateParameters.ts @@ -1,5 +1,6 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, ICoreAddress, ICoreDate } from "@nmshd/core-types"; +import { IPasswordProtectionCreationParameters, PasswordProtectionCreationParameters } from "../../../core/types/PasswordProtectionCreationParameters"; import { validateMaxNumberOfAllocations } from "./CachedRelationshipTemplate"; export interface ISendRelationshipTemplateParameters extends ISerializable { @@ -7,6 +8,7 @@ export interface ISendRelationshipTemplateParameters extends ISerializable { expiresAt: ICoreDate; maxNumberOfAllocations?: number; forIdentity?: ICoreAddress; + passwordProtection?: IPasswordProtectionCreationParameters; } @type("SendRelationshipTemplateParameters") @@ -27,6 +29,10 @@ export class SendRelationshipTemplateParameters extends Serializable implements @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtectionCreationParameters; + public static from(value: ISendRelationshipTemplateParameters): SendRelationshipTemplateParameters { return this.fromAny(value); } diff --git a/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts b/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts index c629e8c1c..695cdec50 100644 --- a/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts +++ b/packages/transport/src/modules/tokens/transmission/TokenContentRelationshipTemplate.ts @@ -1,11 +1,13 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreId, ICoreAddress, ICoreId } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; +import { ISharedPasswordProtection, SharedPasswordProtection } from "../../../core/types/SharedPasswordProtection"; export interface ITokenContentRelationshipTemplate extends ISerializable { templateId: ICoreId; secretKey: ICryptoSecretKey; forIdentity?: ICoreAddress; + passwordProtection?: ISharedPasswordProtection; } @type("TokenContentRelationshipTemplate") @@ -22,6 +24,10 @@ export class TokenContentRelationshipTemplate extends Serializable implements IT @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: SharedPasswordProtection; + public static from(value: ITokenContentRelationshipTemplate): TokenContentRelationshipTemplate { return this.fromAny(value); } diff --git a/packages/transport/test/end2end/End2End.test.ts b/packages/transport/test/end2end/End2End.test.ts index e0f61efd1..56aa855a5 100644 --- a/packages/transport/test/end2end/End2End.test.ts +++ b/packages/transport/test/end2end/End2End.test.ts @@ -77,7 +77,7 @@ describe("RelationshipTest: Accept", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateTo.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateTo.cache!.content as JSONWrapper; @@ -191,7 +191,7 @@ describe("RelationshipTest: Reject", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateTo.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateTo.cache!.content as JSONWrapper; @@ -300,10 +300,7 @@ describe("RelationshipTest: Revoke", function () { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplate( - receivedToken.cache!.content.templateId, - receivedToken.cache!.content.secretKey - ); + const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); expect(templateRequestor.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateRequestor.cache!.content as JSONWrapper; @@ -387,7 +384,7 @@ describe("RelationshipTest: Revoke", function () { const receivedTemplateToken = TokenContentRelationshipTemplate.from(receivedToken.cache!.content as TokenContentRelationshipTemplate); - const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplate(receivedTemplateToken.templateId, receivedTemplateToken.secretKey); + const templateRequestor = await requestor.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedTemplateToken); expect(templateRequestor.cache!.content).toBeInstanceOf(JSONWrapper); const templateContent = templateRequestor.cache!.content as JSONWrapper; @@ -870,7 +867,7 @@ describe("RelationshipTest: operation executioner validation (on pending relatio throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); const request = await to.relationships.sendRelationship({ template: templateTo, creationContent: { diff --git a/packages/transport/test/modules/files/FileReference.test.ts b/packages/transport/test/modules/files/FileReference.test.ts index 1d62a2fda..04e23d1ea 100644 --- a/packages/transport/test/modules/files/FileReference.test.ts +++ b/packages/transport/test/modules/files/FileReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, FileReference } from "../../../src"; +import { BackboneIds, CoreCrypto, FileReference } from "../../../src"; describe("FileReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("FileReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(FileReference); @@ -61,27 +64,32 @@ describe("FileReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = FileReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(FileReference); @@ -90,18 +98,20 @@ describe("FileReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"FileReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as FileReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("FileReference", function () { id: await BackboneIds.file.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("FileReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(FileReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("FileReference", function () { id: await BackboneIds.file.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = FileReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("FileReference", function () { FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("FileReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("FileReference", function () { FileReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.file.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("FileReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => FileReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + FileReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.file.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts index 76833487e..9053c4b9d 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts @@ -1,6 +1,6 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { AccountController, RelationshipTemplate, Transport } from "../../../src"; +import { AccountController, RelationshipTemplate, TokenContentRelationshipTemplate, Transport } from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("RelationshipTemplateController", function () { @@ -49,7 +49,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); tempId1 = sentRelationshipTemplate.id; expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); @@ -67,7 +68,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); tempId2 = sentRelationshipTemplate.id; expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); @@ -77,8 +79,8 @@ describe("RelationshipTemplateController", function () { tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); const sentRelationshipTemplate = await TestUtil.sendRelationshipTemplate(sender); - const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(sentRelationshipTemplate.id, sentRelationshipTemplate.secretKey); - + const templateReference = sentRelationshipTemplate.toRelationshipTemplateReference().truncate(); + const receivedRelationshipTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); expectValidRelationshipTemplates(sentRelationshipTemplate, receivedRelationshipTemplate, tempDate); }); @@ -101,7 +103,8 @@ describe("RelationshipTemplateController", function () { }); expect(ownTemplate).toBeDefined(); - const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplate(ownTemplate.id, ownTemplate.secretKey); + const templateReference = ownTemplate.toRelationshipTemplateReference().truncate(); + const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(templateReference); expect(peerTemplate).toBeDefined(); }); @@ -145,7 +148,80 @@ describe("RelationshipTemplateController", function () { forIdentity: sender.identity.address }); - await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplate(ownTemplate.id, ownTemplate.secretKey)).rejects.toThrow("error.platform.recordNotFound"); + const tokenContent = TokenContentRelationshipTemplate.from({ + templateId: ownTemplate.id, + secretKey: ownTemplate.secretKey + }); + await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(tokenContent)).rejects.toThrow("error.platform.recordNotFound"); + }); + + test("should create and load a password-protected template", async function () { + const ownTemplate = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "password", + passwordType: "pw" + } + }); + expect(ownTemplate).toBeDefined(); + expect(ownTemplate.passwordProtection!.password).toBe("password"); + expect(ownTemplate.passwordProtection!.salt).toBeDefined(); + expect(ownTemplate.passwordProtection!.salt).toHaveLength(16); + expect(ownTemplate.passwordProtection!.passwordType).toBe("pw"); + + const reference = ownTemplate.toRelationshipTemplateReference(); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + expect(reference.passwordProtection!.salt).toStrictEqual(ownTemplate.passwordProtection!.salt); + + const peerTemplate = await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference.truncate(), "password"); + expect(peerTemplate).toBeDefined(); + expect(peerTemplate.passwordProtection!.password).toBe("password"); + expect(peerTemplate.passwordProtection!.salt).toStrictEqual(ownTemplate.passwordProtection!.salt); + expect(peerTemplate.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + const ownTemplate = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "1234", + passwordType: "pin4" + } + }); + expect(ownTemplate).toBeDefined(); + + await expect( + recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate(), "wrongPassword") + ).rejects.toThrow("error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired.'"); + await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate())).rejects.toThrow( + "error.transport.noPasswordProvided" + ); + }); + + test("should fetch multiple password-protected templates", async function () { + const ownTemplate1 = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "password", + passwordType: "pw" + } + }); + const ownTemplate2 = await sender.relationshipTemplates.sendRelationshipTemplate({ + content: { a: "A" }, + expiresAt: CoreDate.utc().add({ minutes: 1 }), + passwordProtection: { + password: "1234", + passwordType: "pin4" + } + }); + + await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate1.toRelationshipTemplateReference().truncate(), "password"); + await recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate2.toRelationshipTemplateReference().truncate(), "1234"); + const fetchCachesResult = await recipient.relationshipTemplates.fetchCaches([ownTemplate1.id, ownTemplate2.id]); + expect(fetchCachesResult).toHaveLength(2); }); test("should send and receive a RelationshipTemplate using a truncated RelationshipTemplateReference", async function () { diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts index 412cd8658..416e5d64f 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, RelationshipTemplateReference } from "../../../src"; +import { BackboneIds, CoreCrypto, RelationshipTemplateReference } from "../../../src"; describe("RelationshipTemplateReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("RelationshipTemplateReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(RelationshipTemplateReference); @@ -61,27 +64,32 @@ describe("RelationshipTemplateReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = RelationshipTemplateReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(RelationshipTemplateReference); @@ -90,18 +98,20 @@ describe("RelationshipTemplateReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"RelationshipTemplateReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as RelationshipTemplateReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("RelationshipTemplateReference", function () { id: await BackboneIds.relationshipTemplate.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("RelationshipTemplateReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(RelationshipTemplateReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("RelationshipTemplateReference", function () { id: await BackboneIds.relationshipTemplate.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = RelationshipTemplateReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("RelationshipTemplateReference", function () { RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("RelationshipTemplateReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("RelationshipTemplateReference", function () { RelationshipTemplateReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.relationshipTemplate.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("RelationshipTemplateReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => RelationshipTemplateReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + RelationshipTemplateReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.relationshipTemplate.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/modules/sync/SyncController.error.test.ts b/packages/transport/test/modules/sync/SyncController.error.test.ts index de9fea63f..022800df1 100644 --- a/packages/transport/test/modules/sync/SyncController.error.test.ts +++ b/packages/transport/test/modules/sync/SyncController.error.test.ts @@ -28,10 +28,8 @@ describe("SyncController.error", function () { // in the ExternalEventsProcessor of templatorDevice2, because the template // doesn't exist on templatorDevice2 - const templateOnRequestorDevice = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await requestorDevice.relationships.sendRelationship({ template: templateOnRequestorDevice, diff --git a/packages/transport/test/modules/sync/SyncController.relationships.test.ts b/packages/transport/test/modules/sync/SyncController.relationships.test.ts index bda4e5d40..5294ec4f4 100644 --- a/packages/transport/test/modules/sync/SyncController.relationships.test.ts +++ b/packages/transport/test/modules/sync/SyncController.relationships.test.ts @@ -25,10 +25,8 @@ describe("RelationshipSync", function () { maxNumberOfAllocations: 1 }); - const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice1.relationships.sendRelationship({ template: templateOnRequestorDevice1, @@ -80,10 +78,8 @@ describe("RelationshipSync", function () { maxNumberOfAllocations: 1 }); - const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice1.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice1.relationships.sendRelationship({ template: templateOnRequestorDevice1, @@ -129,10 +125,8 @@ describe("RelationshipSync", function () { }); await templatorDevice1.syncDatawallet(); - const templateOnRequestorDevice1 = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplate( - templateOnTemplatorDevice.id, - templateOnTemplatorDevice.secretKey - ); + const reference = templateOnTemplatorDevice.toRelationshipTemplateReference().truncate(); + const templateOnRequestorDevice1 = await requestorDevice.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const createdRelationship = await requestorDevice.relationships.sendRelationship({ template: templateOnRequestorDevice1, diff --git a/packages/transport/test/modules/tokens/TokenContent.test.ts b/packages/transport/test/modules/tokens/TokenContent.test.ts index 8143c9d8d..c4c56f6c1 100644 --- a/packages/transport/test/modules/tokens/TokenContent.test.ts +++ b/packages/transport/test/modules/tokens/TokenContent.test.ts @@ -1,8 +1,17 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { JSONWrapper, Serializable } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; -import { CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { AccountController, CoreIdHelper, DeviceSharedSecret, TokenContentDeviceSharedSecret, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; +import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; +import { + AccountController, + CoreCrypto, + CoreIdHelper, + DeviceSharedSecret, + TokenContentDeviceSharedSecret, + TokenContentFile, + TokenContentRelationshipTemplate, + Transport +} from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("TokenContent", function () { @@ -65,7 +74,11 @@ describe("TokenContent", function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -74,7 +87,7 @@ describe("TokenContent", function () { const serialized = token.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` + `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","passwordProtection":{"passwordType":"${token.passwordProtection!.passwordType}","salt":"${token.passwordProtection!.salt.toBase64URL()}"},"secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` ); const deserialized = TokenContentRelationshipTemplate.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); @@ -82,16 +95,23 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); }); test("should serialize and deserialize correctly (no type information)", async function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -105,16 +125,23 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); }); test("should serialize and deserialize correctly (from unknown type)", async function () { const token = TokenContentRelationshipTemplate.from({ secretKey: await CryptoEncryption.generateKey(), templateId: await CoreIdHelper.notPrefixed.generate(), - forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity") + forIdentity: CoreAddress.from("did:e:a-domain:dids:anidentity"), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(token).toBeInstanceOf(Serializable); expect(token).toBeInstanceOf(TokenContentRelationshipTemplate); @@ -123,7 +150,7 @@ describe("TokenContent", function () { const serialized = token.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` + `{"@type":"TokenContentRelationshipTemplate","forIdentity":"${token.forIdentity!.serialize()}","passwordProtection":{"passwordType":"${token.passwordProtection!.passwordType}","salt":"${token.passwordProtection!.salt.toBase64URL()}"},"secretKey":${token.secretKey.serialize(false)},"templateId":"${token.templateId.toString()}"}` ); const deserialized = Serializable.deserializeUnknown(serialized) as TokenContentRelationshipTemplate; expect(deserialized).toBeInstanceOf(Serializable); @@ -131,9 +158,64 @@ describe("TokenContent", function () { expect(deserialized.secretKey).toBeInstanceOf(CryptoSecretKey); expect(deserialized.templateId).toBeInstanceOf(CoreId); expect(deserialized.forIdentity).toBeInstanceOf(CoreAddress); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.secretKey.toBase64()).toStrictEqual(token.secretKey.toBase64()); expect(deserialized.templateId.toString()).toStrictEqual(token.templateId.toString()); expect(deserialized.forIdentity!.toString()).toStrictEqual(token.forIdentity!.toString()); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt).toStrictEqual(token.passwordProtection!.salt); + }); + + test("should not create a tokenContent with too large passwordType", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with non-integer passwordType", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a tokenContent with a salt of wrong length", async function () { + await expect(async () => { + TokenContentRelationshipTemplate.from({ + secretKey: await CryptoEncryption.generateKey(), + templateId: await CoreIdHelper.notPrefixed.generate(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); }); diff --git a/packages/transport/test/modules/tokens/TokenController.test.ts b/packages/transport/test/modules/tokens/TokenController.test.ts index eacefd8fa..2c4821598 100644 --- a/packages/transport/test/modules/tokens/TokenController.test.ts +++ b/packages/transport/test/modules/tokens/TokenController.test.ts @@ -1,8 +1,8 @@ import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; import { JSONWrapper, Serializable } from "@js-soft/ts-serval"; import { CoreDate, CoreId } from "@nmshd/core-types"; -import { CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { AccountController, CoreIdHelper, Token, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; +import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; +import { AccountController, CoreCrypto, CoreIdHelper, Token, TokenContentFile, TokenContentRelationshipTemplate, Transport } from "../../../src"; import { TestUtil } from "../../testHelpers/TestUtil"; describe("TokenController", function () { @@ -59,8 +59,8 @@ describe("TokenController", function () { expiresAt, ephemeral: false }); - const reference = sentToken.toTokenReference().truncate(); - const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + const reference = sentToken.toTokenReference(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference.truncate(), false); tempId1 = sentToken.id; testTokens(sentToken, receivedToken, tempDate); @@ -97,15 +97,17 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentFile); - expect((sentToken.cache?.content as TokenContentFile).fileId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentFile).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentFile; + expect(sentTokenContent.fileId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentFile); - expect((receivedToken.cache?.content as TokenContentFile).fileId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentFile).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentFile).fileId.toString()).toBe(content.fileId.toString()); - expect((sentToken.cache?.content as TokenContentFile).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentFile).fileId.toString()).toBe((sentToken.cache?.content as TokenContentFile).fileId.toString()); - expect((receivedToken.cache?.content as TokenContentFile).secretKey.toBase64()).toBe((sentToken.cache?.content as TokenContentFile).secretKey.toBase64()); + const receivedTokenContent = receivedToken.cache?.content as TokenContentFile; + expect(receivedTokenContent.fileId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.fileId.toString()).toBe(content.fileId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.fileId.toString()).toBe(sentTokenContent.fileId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); }); test("should send and receive a TokenContentRelationshipTemplate", async function () { @@ -125,19 +127,17 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe(content.templateId.toString()); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64() - ); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); }); test("should send and receive a personalized TokenContentRelationshipTemplate", async function () { @@ -158,29 +158,106 @@ describe("TokenController", function () { testTokens(sentToken, receivedToken, tempDate); expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(receivedTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + }); + + test("should send and receive a password-protected TokenContentRelationshipTemplate", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = TokenContentRelationshipTemplate.from({ + templateId: await CoreIdHelper.notPrefixed.generate(), + secretKey: await CryptoEncryption.generateKey(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } + }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.passwordProtection!.passwordType).toBe("pw"); + expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(receivedTokenContent.passwordProtection!.passwordType).toBe(sentTokenContent.passwordProtection!.passwordType); + expect(receivedTokenContent.passwordProtection!.salt.toBase64URL()).toBe(sentTokenContent.passwordProtection!.salt.toBase64URL()); + }); + + test("should send and receive a password-protected and personalized TokenContentRelationshipTemplate", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = TokenContentRelationshipTemplate.from({ + templateId: await CoreIdHelper.notPrefixed.generate(), + secretKey: await CryptoEncryption.generateKey(), + forIdentity: recipient.identity.address, + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } + }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference, false); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); + const sentTokenContent = sentToken.cache?.content as TokenContentRelationshipTemplate; + expect(sentTokenContent.templateId).toBeInstanceOf(CoreId); + expect(sentTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(sentTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.passwordProtection!.passwordType).toBe("pw"); expect(receivedToken.cache?.content).toBeInstanceOf(TokenContentRelationshipTemplate); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId).toBeInstanceOf(CoreId); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey).toBeInstanceOf(CryptoSecretKey); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe(content.templateId.toString()); - expect((sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe(content.secretKey.toBase64()); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).templateId.toString() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).secretKey.toBase64() - ); - expect((receivedToken.cache?.content as TokenContentRelationshipTemplate).forIdentity!.toString()).toBe( - (sentToken.cache?.content as TokenContentRelationshipTemplate).forIdentity!.toString() - ); + const receivedTokenContent = receivedToken.cache?.content as TokenContentRelationshipTemplate; + expect(receivedTokenContent.templateId).toBeInstanceOf(CoreId); + expect(receivedTokenContent.secretKey).toBeInstanceOf(CryptoSecretKey); + expect(receivedTokenContent.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); + expect(sentTokenContent.templateId.toString()).toBe(content.templateId.toString()); + expect(sentTokenContent.secretKey.toBase64()).toBe(content.secretKey.toBase64()); + expect(receivedTokenContent.templateId.toString()).toBe(sentTokenContent.templateId.toString()); + expect(receivedTokenContent.secretKey.toBase64()).toBe(sentTokenContent.secretKey.toBase64()); + expect(sentTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + expect(receivedTokenContent.forIdentity!.toString()).toBe(sentTokenContent.forIdentity!.toString()); + expect(receivedTokenContent.passwordProtection!.passwordType).toBe(sentTokenContent.passwordProtection!.passwordType); + expect(receivedTokenContent.passwordProtection!.salt.toBase64URL()).toBe(sentTokenContent.passwordProtection!.salt.toBase64URL()); }); test("should get the cached tokens", async function () { const sentTokens = await sender.tokens.getTokens(); const receivedTokens = await recipient.tokens.getTokens(); - expect(sentTokens).toHaveLength(4); - expect(receivedTokens).toHaveLength(4); + expect(sentTokens).toHaveLength(6); + expect(receivedTokens).toHaveLength(6); expect(sentTokens[0].id.toString()).toBe(tempId1.toString()); expect(sentTokens[1].id.toString()).toBe(tempId2.toString()); testTokens(sentTokens[0], receivedTokens[0], tempDate); diff --git a/packages/transport/test/modules/tokens/TokenReference.test.ts b/packages/transport/test/modules/tokens/TokenReference.test.ts index 13d41972e..1396f98c3 100644 --- a/packages/transport/test/modules/tokens/TokenReference.test.ts +++ b/packages/transport/test/modules/tokens/TokenReference.test.ts @@ -1,7 +1,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreId } from "@nmshd/core-types"; import { CoreBuffer, CryptoEncryption, CryptoSecretKey } from "@nmshd/crypto"; -import { BackboneIds, TokenReference } from "../../../src"; +import { BackboneIds, CoreCrypto, TokenReference } from "../../../src"; describe("TokenReference", function () { test("should serialize and deserialize correctly (verbose)", async function () { @@ -46,13 +46,16 @@ describe("TokenReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should serialize and deserialize correctly (verbose, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (verbose, with backbone, identity, passwordProtection)", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(TokenReference); @@ -61,27 +64,32 @@ describe("TokenReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pin10","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = TokenReference.deserialize(serialized); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); - test("should serialize and deserialize correctly (from unknown type, with backbone, identity, password)", async function () { + test("should serialize and deserialize correctly (from unknown type, with backbone, identity, passwordProtection)", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(16) + } }); expect(reference).toBeInstanceOf(Serializable); expect(reference).toBeInstanceOf(TokenReference); @@ -90,18 +98,20 @@ describe("TokenReference", function () { const serialized = reference.serialize(); expect(typeof serialized).toBe("string"); expect(serialized).toBe( - `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordType":10}` + `{"@type":"TokenReference","backboneBaseUrl":"localhost","forIdentityTruncated":"1234","id":"${reference.id.toString()}","key":${reference.key.serialize(false)},"passwordProtection":{"passwordType":"pw","salt":"${reference.passwordProtection!.salt.toBase64URL()}"}}` ); const deserialized = Serializable.deserializeUnknown(serialized) as TokenReference; expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pw"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should truncate and read in correctly", async function () { @@ -110,7 +120,7 @@ describe("TokenReference", function () { id: await BackboneIds.token.generateUnsafe() }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -121,27 +131,32 @@ describe("TokenReference", function () { expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); }); - test("should truncate and read in correctly with backbone, identity, password", async function () { + test("should truncate and read in correctly with backbone, identity, passwordProtection", async function () { const reference = TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), backboneBaseUrl: "localhost", forIdentityTruncated: "1234", - passwordType: 10 + passwordProtection: { + passwordType: "pin10", + salt: await CoreCrypto.random(16) + } }); const truncated = reference.truncate(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); expect(deserialized).toBeInstanceOf(TokenReference); expect(deserialized.key).toBeInstanceOf(CryptoSecretKey); expect(deserialized.id).toBeInstanceOf(CoreId); + expect(deserialized.passwordProtection!.salt).toBeInstanceOf(CoreBuffer); expect(deserialized.key.toBase64()).toStrictEqual(reference.key.toBase64()); expect(deserialized.id.toString()).toStrictEqual(reference.id.toString()); expect(deserialized.backboneBaseUrl).toBe("localhost"); expect(deserialized.forIdentityTruncated).toBe("1234"); - expect(deserialized.passwordType).toBe(10); + expect(deserialized.passwordProtection!.passwordType).toBe("pin10"); + expect(deserialized.passwordProtection!.salt.toBase64URL()).toBe(reference.passwordProtection!.salt.toBase64URL()); }); test("should read a reference in the old format", async function () { @@ -150,7 +165,7 @@ describe("TokenReference", function () { id: await BackboneIds.token.generateUnsafe() }); const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}`).toBase64URL(); - expect(truncated.length).toBeLessThan(115); + expect(truncated.length).toBeLessThan(155); expect(truncated.length).toBeGreaterThan(80); const deserialized = TokenReference.fromTruncated(truncated); expect(deserialized).toBeInstanceOf(Serializable); @@ -166,9 +181,12 @@ describe("TokenReference", function () { TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), - passwordType: 20 + passwordProtection: { + passwordType: "pin20", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("TokenReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); }); test("should not create a reference with non-integer passwordType", async function () { @@ -176,9 +194,48 @@ describe("TokenReference", function () { TokenReference.from({ key: await CryptoEncryption.generateKey(), id: await BackboneIds.token.generateUnsafe(), - passwordType: 2.4 + passwordProtection: { + passwordType: "pin2.4", + salt: await CoreCrypto.random(16) + } }); - }).rejects.toThrow("TokenReference.passwordType"); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not create a reference with passwordType starting with neither pw nor pin", async function () { + await expect(async () => { + TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe(), + passwordProtection: { + passwordType: "pc", + salt: await CoreCrypto.random(16) + } + }); + }).rejects.toThrow("SharedPasswordProtection.passwordType"); + }); + + test("should not load a reference with a non-base64 salt", async function () { + const reference = TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe() + }); + + const truncated = CoreBuffer.fromUtf8(`${reference.id.toString()}|${reference.key.algorithm}|${reference.key.secretKey.toBase64URL()}||wrong-salt&pw`).toBase64URL(); + expect(() => TokenReference.fromTruncated(truncated)).toThrow("The salt needs to be a Base64 value."); + }); + + test("should not create a reference with a salt of wrong length", async function () { + await expect(async () => { + TokenReference.from({ + key: await CryptoEncryption.generateKey(), + id: await BackboneIds.token.generateUnsafe(), + passwordProtection: { + passwordType: "pw", + salt: await CoreCrypto.random(8) + } + }); + }).rejects.toThrow("must be 16 bytes long"); }); test("should not create a reference with too long personalization", async function () { diff --git a/packages/transport/test/testHelpers/TestUtil.ts b/packages/transport/test/testHelpers/TestUtil.ts index f7a6b2120..42f09ef77 100644 --- a/packages/transport/test/testHelpers/TestUtil.ts +++ b/packages/transport/test/testHelpers/TestUtil.ts @@ -296,7 +296,8 @@ export class TestUtil { maxNumberOfAllocations: 1 }); - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(templateFrom.id, templateFrom.secretKey); + const reference = templateFrom.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); await to.relationships.sendRelationship({ template: templateTo, @@ -341,7 +342,8 @@ export class TestUtil { to: AccountController, template: RelationshipTemplate ): Promise<{ acceptedRelationshipFromSelf: Relationship; acceptedRelationshipPeer: Relationship }> { - const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplate(template.id, template.secretKey); + const reference = template.toRelationshipTemplateReference().truncate(); + const templateTo = await to.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(reference); const relRequest = await to.relationships.sendRelationship({ template: templateTo, @@ -539,7 +541,7 @@ export class TestUtil { throw new Error("token content not instanceof TokenContentRelationshipTemplate"); } - const template = await account.relationshipTemplates.loadPeerRelationshipTemplate(receivedToken.cache!.content.templateId, receivedToken.cache!.content.secretKey); + const template = await account.relationshipTemplates.loadPeerRelationshipTemplateByTokenContent(receivedToken.cache!.content); return template; } From acb942bc01ddc34edf3460d13ec3c9ebceb7e9cd Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:29:00 +0100 Subject: [PATCH 02/12] Don't compare classes with `_.isEqual` (#341) * refactor: convert to JSON before _.isEqual * refactor: remove any typing --- .../src/modules/attributes/AttributesController.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/consumption/src/modules/attributes/AttributesController.ts b/packages/consumption/src/modules/attributes/AttributesController.ts index 9d8bb5966..a97c7fb6e 100644 --- a/packages/consumption/src/modules/attributes/AttributesController.ts +++ b/packages/consumption/src/modules/attributes/AttributesController.ts @@ -734,13 +734,11 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceAttributeIsNotRepositoryAttribute()); } - if (!_.isEqual(successorSource.content, successor.content)) { + if (!_.isEqual(successorSource.content.toJSON(), successor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceContentIsNotEqualToCopyContent()); } - let predecessorSource: any = undefined; - if (predecessor.shareInfo.sourceAttribute) predecessorSource = await this.getLocalAttribute(predecessor.shareInfo.sourceAttribute); - + const predecessorSource = predecessor.shareInfo.sourceAttribute ? await this.getLocalAttribute(predecessor.shareInfo.sourceAttribute) : undefined; if (predecessorSource) { if (!predecessorSource.isRepositoryAttribute(this.identity.address)) { return ValidationResult.error(ConsumptionCoreErrors.attributes.predecessorSourceAttributeIsNotRepositoryAttribute()); @@ -751,7 +749,7 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.successorSourceDoesNotSucceedPredecessorSource()); } - if (!_.isEqual(predecessorSource.content, predecessor.content)) { + if (!_.isEqual(predecessorSource.content.toJSON(), predecessor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.predecessorSourceContentIsNotEqualToCopyContent()); } } @@ -983,7 +981,7 @@ export class AttributesController extends ConsumptionBaseController { return ValidationResult.error(ConsumptionCoreErrors.attributes.cannotSucceedAttributesWithDeletionInfo()); } - if (_.isEqual(successor.content, predecessor.content)) { + if (_.isEqual(successor.content.toJSON(), predecessor.content.toJSON())) { return ValidationResult.error(ConsumptionCoreErrors.attributes.successionMustChangeContent()); } From c74ebb579effd1404a769924af8b9b8a43c062c7 Mon Sep 17 00:00:00 2001 From: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Date: Wed, 27 Nov 2024 10:30:12 +0100 Subject: [PATCH 03/12] Remove unused injected controller (#343) --- .../src/useCases/transport/relationships/CreateRelationship.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts b/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts index b5f83360d..cf0d8d76a 100644 --- a/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts +++ b/packages/runtime/src/useCases/transport/relationships/CreateRelationship.ts @@ -1,6 +1,5 @@ import { Serializable } from "@js-soft/ts-serval"; import { Result } from "@js-soft/ts-utils"; -import { IncomingRequestsController } from "@nmshd/consumption"; import { ArbitraryRelationshipCreationContent, RelationshipCreationContent } from "@nmshd/content"; import { CoreId } from "@nmshd/core-types"; import { AccountController, RelationshipTemplate, RelationshipTemplateController, RelationshipsController } from "@nmshd/transport"; @@ -24,7 +23,6 @@ export class CreateRelationshipUseCase extends UseCase Date: Wed, 27 Nov 2024 10:43:15 +0100 Subject: [PATCH 04/12] Allow passing no title and description when uploading a file (#342) * refactor: update SendFileParameters * refactor: make title optional * chore: update schemas --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/runtime/src/useCases/common/Schemas.ts | 6 ++---- .../useCases/transport/files/UploadOwnFile.ts | 9 +++------ .../modules/files/local/SendFileParameters.ts | 16 ++++++++++------ 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 87e5c1967..178cd5b8f 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -21641,8 +21641,7 @@ export const UploadOwnFileRequest: any = { "required": [ "content", "filename", - "mimetype", - "title" + "mimetype" ], "additionalProperties": false }, @@ -21683,8 +21682,7 @@ export const UploadOwnFileValidatableRequest: any = { "required": [ "content", "filename", - "mimetype", - "title" + "mimetype" ], "additionalProperties": false }, diff --git a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts index 5ac012de9..fc81f0c40 100644 --- a/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts +++ b/packages/runtime/src/useCases/transport/files/UploadOwnFile.ts @@ -13,7 +13,7 @@ export interface UploadOwnFileRequest { filename: string; mimetype: string; expiresAt?: ISO8601DateTimeString; - title: string; + title?: string; description?: string; } @@ -77,16 +77,13 @@ export class UploadOwnFileUseCase extends UseCase } protected async executeInternal(request: UploadOwnFileRequest): Promise> { - const maxDate = "9999-12-31T00:00:00.000Z"; - const expiresAt = request.expiresAt ?? maxDate; - const file = await this.fileController.sendFile({ buffer: CoreBuffer.from(request.content), title: request.title, - description: request.description ?? "", + description: request.description, filename: request.filename, mimetype: request.mimetype, - expiresAt: CoreDate.from(expiresAt) + expiresAt: CoreDate.from(request.expiresAt ?? "9999-12-31T00:00:00.000Z") }); await this.accountController.syncDatawallet(); diff --git a/packages/transport/src/modules/files/local/SendFileParameters.ts b/packages/transport/src/modules/files/local/SendFileParameters.ts index 37e1a8f80..b4fbade67 100644 --- a/packages/transport/src/modules/files/local/SendFileParameters.ts +++ b/packages/transport/src/modules/files/local/SendFileParameters.ts @@ -3,8 +3,8 @@ import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; export interface ISendFileParameters extends ISerializable { - title: string; - description: string; + title?: string; + description?: string; filename: string; mimetype: string; expiresAt: ICoreDate; @@ -14,18 +14,22 @@ export interface ISendFileParameters extends ISerializable { @type("SendFileParameters") export class SendFileParameters extends Serializable implements ISendFileParameters { - @validate() + @validate({ nullable: true }) @serialize() - public title: string; - @validate() + public title?: string; + + @validate({ nullable: true }) @serialize() - public description: string; + public description?: string; + @validate() @serialize() public filename: string; + @validate() @serialize() public mimetype: string; + @validate() @serialize() public expiresAt: CoreDate; From 4b41c4ffbbf623e1d02608c05062f9790e0061a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Wed, 27 Nov 2024 12:14:53 +0100 Subject: [PATCH 05/12] Upgrade Backbone Version (#344) * chore: bump backbone * fix: update error message --- .dev/appsettings.override.json | 5 +++++ .dev/compose.backbone.env | 2 +- .../RelationshipTemplateController.test.ts | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.dev/appsettings.override.json b/.dev/appsettings.override.json index e655dfaec..167f4fd5f 100644 --- a/.dev/appsettings.override.json +++ b/.dev/appsettings.override.json @@ -103,6 +103,11 @@ "ConnectionString": "User ID=tokens;Password=Passw0rd;Server=postgres;Port=5432;Database=enmeshed;" } } + }, + "Tags": { + "Application": { + "SupportedLanguages": ["en"] + } } }, "Serilog": { diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index 8115afe54..a3a499d00 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.15.2 +BACKBONE_VERSION=6.19.1 diff --git a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts index 9053c4b9d..1bf019b38 100644 --- a/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts +++ b/packages/transport/test/modules/relationshipTemplates/RelationshipTemplateController.test.ts @@ -194,7 +194,9 @@ describe("RelationshipTemplateController", function () { await expect( recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate(), "wrongPassword") - ).rejects.toThrow("error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired.'"); + ).rejects.toThrow( + "error.platform.recordNotFound (404): 'RelationshipTemplate not found. Make sure the ID exists and the record is not expired. If a password is required to fetch the record, make sure you passed the correct one.'" + ); await expect(recipient.relationshipTemplates.loadPeerRelationshipTemplateByTruncated(ownTemplate.toRelationshipTemplateReference().truncate())).rejects.toThrow( "error.transport.noPasswordProvided" ); From 226d09b4996a9c40403b91766b1a85bc57da47ff Mon Sep 17 00:00:00 2001 From: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:40:14 +0100 Subject: [PATCH 06/12] Add `deletionDate` to LocalAccount (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP: Enhance LocalAccountDTO with deletion Info * feat: try to untangle changes * feat: add getAccounts(Not)InDeletion functions * test: clean up IdentityDeletionProcessStatusChangedModule test * refactor: stuff * refactor: clean up changes * test: notes * feat: publish DatawalletSynchronizedEvent in AccountController * test: receive DatawalletSynchronizedEvent calling syncDatawallet use case * feat: use runtime event in app-runtime * feat: add DatawalletSynchronized module * chore: remove LocalAccountDeletionDateChangedModule * test: clean up IdentityDeletionProcessStatusChangedModule test * fix: DatawalletSynchronizedModule * fix: don't publish event updating LocalAccount deletionDate * fix and test: getAccounts(Not)InDeletion * test: don's skip tests * test: remove unrelated test * chore: remove dangerous getters * fix: write deletionDate to cached local account from MultiAccountController * fix: use correct apis * refactor: massively simplify tests * chore: naming * chore: more asserts * refactor: move event publish location * fix: change location of publishing * feat: make deletionDate CoreDate * test: refactor afterAll and afterEach function * test: wording * test: refactor functions * test: compare strings * chore: one-liners * feat: provide LocalAccountDTO in LocalAccountDeletionDateChangedEvent * refactor: move variable closer to its usage * refactor: use at-method to access last array element --------- Co-authored-by: Siolto Co-authored-by: Julian König --- packages/app-runtime/src/AppConfig.ts | 12 +++ packages/app-runtime/src/AppRuntime.ts | 28 ++----- .../src/events/DatawalletSynchronizedEvent.ts | 9 -- .../LocalAccountDeletionDateChangedEvent.ts | 10 +++ packages/app-runtime/src/events/index.ts | 2 +- .../PushNotificationModule.ts | 3 +- .../DatawalletSynchronizedModule.ts | 59 +++++++++++++ ...ntityDeletionProcessStatusChangedModule.ts | 44 ++++++++++ .../src/modules/runtimeEvents/index.ts | 2 + .../multiAccount/MultiAccountController.ts | 29 +++++++ .../src/multiAccount/data/LocalAccount.ts | 11 ++- .../src/multiAccount/data/LocalAccountDTO.ts | 1 + .../multiAccount/data/LocalAccountMapper.ts | 3 +- .../DatawalletSynchronizedModule.test.ts | 83 +++++++++++++++++++ ...DeletionProcessStatusChangedModule.test.ts | 51 ++++++++++++ .../test/modules/PushNotification.test.ts | 3 +- .../MultiAccountController.test.ts | 66 +++++++++++++++ packages/runtime/src/events/EventProxy.ts | 5 ++ .../transport/DatawalletSynchronizedEvent.ts | 9 ++ .../runtime/src/events/transport/index.ts | 1 + .../runtime/test/transport/account.test.ts | 17 +++- .../src/events/DatawalletSynchronizedEvent.ts | 9 ++ packages/transport/src/events/index.ts | 1 + .../src/modules/accounts/AccountController.ts | 6 +- .../src/modules/sync/SyncController.ts | 3 + 25 files changed, 421 insertions(+), 46 deletions(-) delete mode 100644 packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts create mode 100644 packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts create mode 100644 packages/app-runtime/src/modules/runtimeEvents/DatawalletSynchronizedModule.ts create mode 100644 packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts create mode 100644 packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts create mode 100644 packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts create mode 100644 packages/app-runtime/test/multiAccount/MultiAccountController.test.ts create mode 100644 packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts create mode 100644 packages/transport/src/events/DatawalletSynchronizedEvent.ts diff --git a/packages/app-runtime/src/AppConfig.ts b/packages/app-runtime/src/AppConfig.ts index 8f5144bc1..b92b91798 100644 --- a/packages/app-runtime/src/AppConfig.ts +++ b/packages/app-runtime/src/AppConfig.ts @@ -52,6 +52,18 @@ export function createAppConfig(...configs: AppConfigOverwrite[]): AppConfig { location: "onboardingChangeReceived", enabled: true }, + datawalletSynchronized: { + name: "datawalletSynchronized", + displayName: "Datawallet Synchronized Module", + location: "datawalletSynchronized", + enabled: true + }, + identityDeletionProcessStatusChanged: { + name: "identityDeletionProcessStatusChanged", + displayName: "Identity Deletion Process Status Changed Module", + location: "identityDeletionProcessStatusChanged", + enabled: true + }, messageReceived: { name: "messageReceived", displayName: "Message Received Module", diff --git a/packages/app-runtime/src/AppRuntime.ts b/packages/app-runtime/src/AppRuntime.ts index 8538ecca9..6852d4327 100644 --- a/packages/app-runtime/src/AppRuntime.ts +++ b/packages/app-runtime/src/AppRuntime.ts @@ -9,13 +9,15 @@ import { AppConfig, AppConfigOverwrite, createAppConfig } from "./AppConfig"; import { AppRuntimeErrors } from "./AppRuntimeErrors"; import { AppRuntimeServices } from "./AppRuntimeServices"; import { AppStringProcessor } from "./AppStringProcessor"; -import { AccountSelectedEvent, RelationshipSelectedEvent } from "./events"; +import { AccountSelectedEvent } from "./events"; import { AppServices, IUIBridge } from "./extensibility"; import { AppLaunchModule, AppRuntimeModuleConfiguration, AppSyncModule, + DatawalletSynchronizedModule, IAppRuntimeModuleConstructor, + IdentityDeletionProcessStatusChangedModule, MailReceivedModule, MessageReceivedModule, OnboardingChangeReceivedModule, @@ -86,14 +88,6 @@ export class AppRuntime extends Runtime { private readonly sessionStorage = new SessionStorage(); - public get currentAccount(): LocalAccountDTO { - return this.sessionStorage.currentSession.account; - } - - public get currentSession(): LocalAccountSession { - return this.sessionStorage.currentSession; - } - public getSessions(): LocalAccountSession[] { return this.sessionStorage.getSessions(); } @@ -209,20 +203,6 @@ export class AppRuntime extends Runtime { return UserfriendlyResult.ok(accountSelectionResult.value); } - public async selectRelationship(id?: string): Promise { - if (!id) { - this.currentSession.selectedRelationship = undefined; - return; - } - - const result = await this.currentSession.appServices.relationships.renderRelationship(id); - if (result.isError) throw result.error; - - const relationship = result.value; - this.currentSession.selectedRelationship = relationship; - this.eventBus.publish(new RelationshipSelectedEvent(this.currentSession.address, relationship)); - } - public getHealth(): Promise { const health = { isHealthy: true, @@ -298,6 +278,8 @@ export class AppRuntime extends Runtime { pushNotification: PushNotificationModule, mailReceived: MailReceivedModule, onboardingChangeReceived: OnboardingChangeReceivedModule, + datawalletSynchronized: DatawalletSynchronizedModule, + identityDeletionProcessStatusChanged: IdentityDeletionProcessStatusChangedModule, messageReceived: MessageReceivedModule, relationshipChanged: RelationshipChangedModule, relationshipTemplateProcessed: RelationshipTemplateProcessedModule diff --git a/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts b/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts deleted file mode 100644 index a9d01fa22..000000000 --- a/packages/app-runtime/src/events/DatawalletSynchronizedEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DataEvent } from "@nmshd/runtime"; - -export class DatawalletSynchronizedEvent extends DataEvent { - public static readonly namespace: string = "app.datawalletSynchronized"; - - public constructor(address: string) { - super(DatawalletSynchronizedEvent.namespace, address, undefined); - } -} diff --git a/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts b/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts new file mode 100644 index 000000000..2f106af6e --- /dev/null +++ b/packages/app-runtime/src/events/LocalAccountDeletionDateChangedEvent.ts @@ -0,0 +1,10 @@ +import { DataEvent } from "@nmshd/runtime"; +import { LocalAccountDTO } from "../multiAccount"; + +export class LocalAccountDeletionDateChangedEvent extends DataEvent { + public static readonly namespace: string = "app.localAccountDeletionDateChanged"; + + public constructor(address: string, localAccount: LocalAccountDTO) { + super(LocalAccountDeletionDateChangedEvent.namespace, address, localAccount); + } +} diff --git a/packages/app-runtime/src/events/index.ts b/packages/app-runtime/src/events/index.ts index 5b512897c..be009dd2b 100644 --- a/packages/app-runtime/src/events/index.ts +++ b/packages/app-runtime/src/events/index.ts @@ -1,6 +1,6 @@ export * from "./AccountSelectedEvent"; -export * from "./DatawalletSynchronizedEvent"; export * from "./ExternalEventReceivedEvent"; +export * from "./LocalAccountDeletionDateChangedEvent"; export * from "./MailReceivedEvent"; export * from "./OnboardingChangeReceivedEvent"; export * from "./RelationshipSelectedEvent"; diff --git a/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts b/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts index 92df666a9..31f9141d2 100644 --- a/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts +++ b/packages/app-runtime/src/modules/pushNotifications/PushNotificationModule.ts @@ -1,6 +1,6 @@ import { Result } from "@js-soft/ts-utils"; import { AppRuntimeErrors } from "../../AppRuntimeErrors"; -import { AccountSelectedEvent, DatawalletSynchronizedEvent, ExternalEventReceivedEvent } from "../../events"; +import { AccountSelectedEvent, ExternalEventReceivedEvent } from "../../events"; import { RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../natives"; import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; import { BackboneEventName, IBackboneEventContent } from "./IBackboneEventContent"; @@ -35,7 +35,6 @@ export class PushNotificationModule extends AppRuntimeModule { + public async init(): Promise { + // Nothing to do here + } + + public start(): Promise | void { + this.subscribeToEvent(DatawalletSynchronizedEvent, this.handleDatawalletSynchronized.bind(this)); + } + + private async handleDatawalletSynchronized(event: DatawalletSynchronizedEvent) { + const services = await this.runtime.getServices(event.eventTargetAddress); + const identityDeletionProcessResult = await services.transportServices.identityDeletionProcesses.getIdentityDeletionProcesses(); + + if (identityDeletionProcessResult.isError) { + this.logger.error(identityDeletionProcessResult); + return; + } + + if (identityDeletionProcessResult.value.length === 0) return; + + const mostRecentIdentityDeletionProcess = identityDeletionProcessResult.value.at(-1)!; + let newDeletionDate; + switch (mostRecentIdentityDeletionProcess.status) { + case IdentityDeletionProcessStatus.Approved: + newDeletionDate = CoreDate.from(mostRecentIdentityDeletionProcess.gracePeriodEndsAt!); + break; + case IdentityDeletionProcessStatus.Cancelled: + case IdentityDeletionProcessStatus.Rejected: + case IdentityDeletionProcessStatus.WaitingForApproval: + newDeletionDate = undefined; + break; + } + + const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + const previousDeletionDate = account.deletionDate; + + if (previousDeletionDate === newDeletionDate) return; + + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, newDeletionDate); + + const updatedAccount = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + this.runtime.eventBus.publish(new LocalAccountDeletionDateChangedEvent(event.eventTargetAddress, LocalAccountMapper.toLocalAccountDTO(updatedAccount))); + } + + public override stop(): Promise | void { + this.unsubscribeFromAllEvents(); + } +} diff --git a/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts new file mode 100644 index 000000000..a8306b593 --- /dev/null +++ b/packages/app-runtime/src/modules/runtimeEvents/IdentityDeletionProcessStatusChangedModule.ts @@ -0,0 +1,44 @@ +import { CoreDate } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus, IdentityDeletionProcessStatusChangedEvent } from "@nmshd/runtime"; +import { AppRuntimeError } from "../../AppRuntimeError"; +import { AppRuntimeModule, AppRuntimeModuleConfiguration } from "../AppRuntimeModule"; + +export interface IdentityDeletionProcessStatusChangedModuleConfig extends AppRuntimeModuleConfiguration {} + +export class IdentityDeletionProcessChangedModuleError extends AppRuntimeError {} + +export class IdentityDeletionProcessStatusChangedModule extends AppRuntimeModule { + public async init(): Promise { + // Nothing to do here + } + + public start(): Promise | void { + this.subscribeToEvent(IdentityDeletionProcessStatusChangedEvent, this.handleIdentityDeletionProcessStatusChanged.bind(this)); + } + + private async handleIdentityDeletionProcessStatusChanged(event: IdentityDeletionProcessStatusChangedEvent) { + const identityDeletionProcess = event.data; + + switch (identityDeletionProcess.status) { + case IdentityDeletionProcessStatus.Approved: + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, CoreDate.from(identityDeletionProcess.gracePeriodEndsAt!)); + break; + + case IdentityDeletionProcessStatus.Cancelled: + const account = await this.runtime.multiAccountController.getAccountByAddress(event.eventTargetAddress); + const previousDeletionDate = account.deletionDate; + + if (!previousDeletionDate) break; + + await this.runtime.multiAccountController.updateLocalAccountDeletionDate(event.eventTargetAddress, undefined); + break; + + default: + break; + } + } + + public override stop(): Promise | void { + this.unsubscribeFromAllEvents(); + } +} diff --git a/packages/app-runtime/src/modules/runtimeEvents/index.ts b/packages/app-runtime/src/modules/runtimeEvents/index.ts index 69825c2de..e37a1aa45 100644 --- a/packages/app-runtime/src/modules/runtimeEvents/index.ts +++ b/packages/app-runtime/src/modules/runtimeEvents/index.ts @@ -1,2 +1,4 @@ +export * from "./DatawalletSynchronizedModule"; +export * from "./IdentityDeletionProcessStatusChangedModule"; export * from "./MessageReceivedModule"; export * from "./RelationshipChangedModule"; diff --git a/packages/app-runtime/src/multiAccount/MultiAccountController.ts b/packages/app-runtime/src/multiAccount/MultiAccountController.ts index 55728c7ec..252590aa1 100644 --- a/packages/app-runtime/src/multiAccount/MultiAccountController.ts +++ b/packages/app-runtime/src/multiAccount/MultiAccountController.ts @@ -42,6 +42,7 @@ export class MultiAccountController { this._dbClosed = false; this._localAccounts = await this._db.getCollection("LocalAccounts"); + return this; } @@ -81,6 +82,18 @@ export class MultiAccountController { return dbAccounts.map((account) => LocalAccount.from(account)); } + public async getAccountsInDeletion(): Promise { + const allAccounts = await this.getAccounts(); + const accountsInDeletion = allAccounts.filter((item) => item.deletionDate !== undefined); + return accountsInDeletion; + } + + public async getAccountsNotInDeletion(): Promise { + const allAccounts = await this.getAccounts(); + const accountsNotInDeletion = allAccounts.filter((item) => item.deletionDate === undefined); + return accountsNotInDeletion; + } + public async selectAccount(id: CoreId): Promise<[LocalAccount, AccountController]> { this._log.trace(`Selecting LocalAccount with id ${id}...`); const account = await this._localAccounts.read(id.toString()); @@ -238,6 +251,22 @@ export class MultiAccountController { await this._localAccounts.update(oldAccount, renamedAccount); } + public async updateLocalAccountDeletionDate(address: string, deletionDate?: CoreDate): Promise { + const oldAccount = await this._localAccounts.findOne({ address }); + + if (!oldAccount) { + throw TransportCoreErrors.general.recordNotFound(LocalAccount, address).logWith(this._log); + } + + const account = LocalAccount.from(oldAccount); + + account.deletionDate = deletionDate ?? undefined; + await this._localAccounts.update(oldAccount, account); + + const cachedAccount = this.sessionStorage.findSession(address)?.account; + if (cachedAccount) cachedAccount.deletionDate = deletionDate?.toString(); + } + public async updateLastAccessedAt(accountId: string): Promise { const document = await this._localAccounts.read(accountId); if (!document) { diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccount.ts b/packages/app-runtime/src/multiAccount/data/LocalAccount.ts index 4bcab0860..e2cb890cc 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccount.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccount.ts @@ -1,14 +1,15 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; -import { CoreAddress, CoreDate, CoreId, ICoreDate } from "@nmshd/core-types"; +import { CoreAddress, CoreDate, CoreId, ICoreAddress, ICoreDate, ICoreId } from "@nmshd/core-types"; export interface ILocalAccount extends ISerializable { - id: CoreId; - address?: CoreAddress; + id: ICoreId; + address?: ICoreAddress; name: string; directory: string; order: number; lastAccessedAt?: ICoreDate; devicePushIdentifier?: string; + deletionDate?: ICoreDate; } @type("LocalAccount") @@ -41,6 +42,10 @@ export class LocalAccount extends Serializable implements ILocalAccount { @serialize() public devicePushIdentifier?: string; + @validate({ nullable: true }) + @serialize() + public deletionDate?: CoreDate; + public static from(value: ILocalAccount): LocalAccount { return this.fromAny(value); } diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts b/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts index 69bb38ede..43724520c 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccountDTO.ts @@ -6,4 +6,5 @@ export interface LocalAccountDTO { order: number; lastAccessedAt?: string; devicePushIdentifier?: string; + deletionDate?: string; } diff --git a/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts b/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts index 4de37fd74..8e05f3a87 100644 --- a/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts +++ b/packages/app-runtime/src/multiAccount/data/LocalAccountMapper.ts @@ -10,7 +10,8 @@ export class LocalAccountMapper { directory: localAccount.directory.toString(), order: localAccount.order, lastAccessedAt: localAccount.lastAccessedAt?.toString(), - devicePushIdentifier: localAccount.devicePushIdentifier + devicePushIdentifier: localAccount.devicePushIdentifier, + deletionDate: localAccount.deletionDate?.toString() }; } } diff --git a/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts b/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts new file mode 100644 index 000000000..961aa0f73 --- /dev/null +++ b/packages/app-runtime/test/modules/DatawalletSynchronizedModule.test.ts @@ -0,0 +1,83 @@ +import { CoreId } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountDeletionDateChangedEvent, LocalAccountMapper, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("DatawalletSynchronized", function () { + let runtimeDevice1: AppRuntime; + let sessionDevice1: LocalAccountSession; + + let runtimeDevice2: AppRuntime; + let sessionDevice2: LocalAccountSession; + + beforeAll(async function () { + runtimeDevice1 = await TestUtil.createRuntime(); + await runtimeDevice1.start(); + + const [localAccountDevice1] = await TestUtil.provideAccounts(runtimeDevice1, 1); + sessionDevice1 = await runtimeDevice1.selectAccount(localAccountDevice1.id); + + runtimeDevice2 = await TestUtil.createRuntime(); + await runtimeDevice2.start(); + + const createDeviceResult = await sessionDevice1.transportServices.devices.createDevice({ name: "test", isAdmin: true }); + const onboardingInfoResult = await sessionDevice1.transportServices.devices.getDeviceOnboardingInfo({ id: createDeviceResult.value.id, profileName: "Test" }); + const localAccountDevice2 = await runtimeDevice2.accountServices.onboardAccount(onboardingInfoResult.value); + sessionDevice2 = await runtimeDevice2.selectAccount(localAccountDevice2.id.toString()); + + await sessionDevice1.transportServices.account.syncDatawallet(); + await sessionDevice2.transportServices.account.syncDatawallet(); + }); + + afterEach(async () => { + const activeIdentityDeletionProcess = await sessionDevice1.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) return; + + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + const abortResult = await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + if (abortResult.isError) throw abortResult.error; + + await sessionDevice2.transportServices.account.syncDatawallet(); + await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + } + }); + + afterAll(async function () { + await runtimeDevice1.stop(); + await runtimeDevice2.stop(); + }); + + test("should set the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is initiated", async function () { + const initiateDeletionResult = await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(sessionDevice2.account.deletionDate).toBeUndefined(); + + await sessionDevice2.transportServices.account.syncDatawallet(); + const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); + + expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); + expect(event.data.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + }); + + test("should unset the deletionDate on the LocalAccount on a second device when an IdentityDeletionProcess is cancelled", async function () { + await sessionDevice1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await sessionDevice2.transportServices.account.syncDatawallet(); + await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + + await sessionDevice1.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + expect(sessionDevice2.account.deletionDate).toBeDefined(); + + await sessionDevice2.transportServices.account.syncDatawallet(); + const event = await TestUtil.awaitEvent(runtimeDevice2, LocalAccountDeletionDateChangedEvent); + const updatedAccount = await runtimeDevice2.multiAccountController.getAccountByAddress(sessionDevice2.account.address!); + + expect(event.data).toStrictEqual(LocalAccountMapper.toLocalAccountDTO(updatedAccount)); + expect(event.data.deletionDate).toBeUndefined(); + + const account = await runtimeDevice2.multiAccountController.getAccount(CoreId.from(sessionDevice2.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); +}); diff --git a/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts new file mode 100644 index 000000000..88a4648ec --- /dev/null +++ b/packages/app-runtime/test/modules/IdentityDeletionProcessStatusChangedModule.test.ts @@ -0,0 +1,51 @@ +import { CoreId } from "@nmshd/core-types"; +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("IdentityDeletionProcessStatusChanged", function () { + let runtime: AppRuntime; + let session: LocalAccountSession; + + beforeAll(async function () { + runtime = await TestUtil.createRuntime(); + await runtime.start(); + + const [localAccount] = await TestUtil.provideAccounts(runtime, 1); + session = await runtime.selectAccount(localAccount.id); + }); + + afterEach(async () => { + const activeIdentityDeletionProcess = await session.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) return; + + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + const abortResult = await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + if (abortResult.isError) throw abortResult.error; + } + }); + + afterAll(async () => await runtime.stop()); + + test("should set the deletionDate on the LocalAccount initiating an IdentityDeletionProcess", async function () { + expect(session.account.deletionDate).toBeUndefined(); + + const initiateDeletionResult = await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + expect(session.account.deletionDate).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + + const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); + expect(account.deletionDate!.toString()).toBe(initiateDeletionResult.value.gracePeriodEndsAt); + }); + + test("should unset the deletionDate on the LocalAccount cancelling an IdentityDeletionProcess", async function () { + await session.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + expect(session.account.deletionDate).toBeDefined(); + + await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + expect(session.account.deletionDate).toBeUndefined(); + + const account = await runtime.multiAccountController.getAccount(CoreId.from(session.account.id)); + expect(account.deletionDate).toBeUndefined(); + }); +}); diff --git a/packages/app-runtime/test/modules/PushNotification.test.ts b/packages/app-runtime/test/modules/PushNotification.test.ts index 87a7a8608..329759aca 100644 --- a/packages/app-runtime/test/modules/PushNotification.test.ts +++ b/packages/app-runtime/test/modules/PushNotification.test.ts @@ -1,5 +1,6 @@ import { sleep } from "@js-soft/ts-utils"; -import { AppRuntime, DatawalletSynchronizedEvent, ExternalEventReceivedEvent, LocalAccountSession, RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../src"; +import { DatawalletSynchronizedEvent } from "@nmshd/runtime"; +import { AppRuntime, ExternalEventReceivedEvent, LocalAccountSession, RemoteNotificationEvent, RemoteNotificationRegistrationEvent } from "../../src"; import { TestUtil } from "../lib"; describe("PushNotificationModuleTest", function () { diff --git a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts new file mode 100644 index 000000000..22863881b --- /dev/null +++ b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts @@ -0,0 +1,66 @@ +import { IdentityDeletionProcessStatus } from "@nmshd/runtime"; +import { AppRuntime, LocalAccountDTO, LocalAccountSession } from "../../src"; +import { TestUtil } from "../lib"; + +describe("MultiAccountController", function () { + let runtime: AppRuntime; + + let account1: LocalAccountDTO; + let account2: LocalAccountDTO; + let account3: LocalAccountDTO; + + let session1: LocalAccountSession; + let session2: LocalAccountSession; + let session3: LocalAccountSession; + + beforeAll(async function () { + runtime = await TestUtil.createRuntime(); + await runtime.start(); + + [account1, account2, account3] = await TestUtil.provideAccounts(runtime, 3); + + session1 = await runtime.selectAccount(account1.id); + session2 = await runtime.selectAccount(account2.id); + session3 = await runtime.selectAccount(account3.id); + }); + + afterEach(async () => { + for (const session of [session1, session2, session3]) { + const activeIdentityDeletionProcess = await session.transportServices.identityDeletionProcesses.getActiveIdentityDeletionProcess(); + if (!activeIdentityDeletionProcess.isSuccess) { + return; + } + + let abortResult; + if (activeIdentityDeletionProcess.value.status === IdentityDeletionProcessStatus.Approved) { + abortResult = await session.transportServices.identityDeletionProcesses.cancelIdentityDeletionProcess(); + } + if (abortResult?.isError) throw abortResult.error; + } + }); + + afterAll(async () => await runtime.stop()); + + test("should get all accounts in deletion", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + const accountsInDeletion = await runtime.multiAccountController.getAccountsInDeletion(); + expect(accountsInDeletion).toHaveLength(2); + + const addressesInDeletion = accountsInDeletion.map((account) => account.address!.toString()); + expect(addressesInDeletion).toContain(account1.address); + expect(addressesInDeletion).toContain(account2.address); + }); + + test("should get all accounts not in deletion", async function () { + await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); + + const accountsNotInDeletion = await runtime.multiAccountController.getAccountsNotInDeletion(); + expect(accountsNotInDeletion).toHaveLength(1); + + const addressesNotInDeletion = accountsNotInDeletion.map((account) => account.address!.toString()); + expect(addressesNotInDeletion).toContain(account3.address); + }); +}); diff --git a/packages/runtime/src/events/EventProxy.ts b/packages/runtime/src/events/EventProxy.ts index fdbf0f1eb..020e8ad17 100644 --- a/packages/runtime/src/events/EventProxy.ts +++ b/packages/runtime/src/events/EventProxy.ts @@ -23,6 +23,7 @@ import { ThirdPartyRelationshipAttributeSucceededEvent } from "./consumption"; import { + DatawalletSynchronizedEvent, IdentityDeletionProcessStatusChangedEvent, MessageDeliveredEvent, MessageReceivedEvent, @@ -108,6 +109,10 @@ export class EventProxy { this.subscribeToSourceEvent(transport.PeerDeletionCancelledEvent, (event) => { this.targetEventBus.publish(new PeerDeletionCancelledEvent(event.eventTargetAddress, RelationshipMapper.toRelationshipDTO(event.data))); }); + + this.subscribeToSourceEvent(transport.DatawalletSynchronizedEvent, (event) => { + this.targetEventBus.publish(new DatawalletSynchronizedEvent(event.eventTargetAddress)); + }); } private proxyConsumptionEvents() { diff --git a/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts b/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts new file mode 100644 index 000000000..1e641701f --- /dev/null +++ b/packages/runtime/src/events/transport/DatawalletSynchronizedEvent.ts @@ -0,0 +1,9 @@ +import { DataEvent } from "../DataEvent"; + +export class DatawalletSynchronizedEvent extends DataEvent { + public static readonly namespace: string = "transport.datawalletSynchronized"; + + public constructor(eventTargetAddress: string) { + super(DatawalletSynchronizedEvent.namespace, eventTargetAddress, undefined); + } +} diff --git a/packages/runtime/src/events/transport/index.ts b/packages/runtime/src/events/transport/index.ts index ea53d15f8..fc004346d 100644 --- a/packages/runtime/src/events/transport/index.ts +++ b/packages/runtime/src/events/transport/index.ts @@ -1,3 +1,4 @@ +export * from "./DatawalletSynchronizedEvent"; export * from "./IdentityDeletionProcessStatusChangedEvent"; export * from "./MessageDeliveredEvent"; export * from "./MessageReceivedEvent"; diff --git a/packages/runtime/test/transport/account.test.ts b/packages/runtime/test/transport/account.test.ts index 9697b9a4d..f6de20dd8 100644 --- a/packages/runtime/test/transport/account.test.ts +++ b/packages/runtime/test/transport/account.test.ts @@ -1,17 +1,24 @@ import { CoreDate } from "@nmshd/core-types"; import { DateTime } from "luxon"; -import { DeviceDTO, DeviceOnboardingInfoDTO, TransportServices } from "../../src"; -import { emptyRelationshipTemplateContent, RuntimeServiceProvider, uploadFile } from "../lib"; +import { DatawalletSynchronizedEvent, DeviceDTO, DeviceOnboardingInfoDTO, TransportServices } from "../../src"; +import { emptyRelationshipTemplateContent, MockEventBus, RuntimeServiceProvider, uploadFile } from "../lib"; const serviceProvider = new RuntimeServiceProvider(); let sTransportServices: TransportServices; let rTransportServices: TransportServices; +let sEventBus: MockEventBus; + beforeAll(async () => { const runtimeServices = await serviceProvider.launch(2, { enableDatawallet: true }); sTransportServices = runtimeServices[0].transport; rTransportServices = runtimeServices[1].transport; + + sEventBus = runtimeServices[0].eventBus; }, 30000); + +beforeEach(() => sEventBus.reset()); + afterAll(async () => await serviceProvider.stop()); describe("Sync", () => { @@ -49,6 +56,12 @@ describe("Automatic Datawallet Sync", () => { expect(oldSyncTime).not.toStrictEqual(newSyncTime); }); + test("should receive a DatawalletSynchronizedEvent", async () => { + await sTransportServices.account.syncDatawallet(); + + await expect(sEventBus).toHavePublished(DatawalletSynchronizedEvent); + }); + test("should not run an automatic datawallet sync", async () => { const disableResult = await sTransportServices.account.disableAutoSync(); expect(disableResult).toBeSuccessful(); diff --git a/packages/transport/src/events/DatawalletSynchronizedEvent.ts b/packages/transport/src/events/DatawalletSynchronizedEvent.ts new file mode 100644 index 000000000..7ac5665bd --- /dev/null +++ b/packages/transport/src/events/DatawalletSynchronizedEvent.ts @@ -0,0 +1,9 @@ +import { TransportDataEvent } from "./TransportDataEvent"; + +export class DatawalletSynchronizedEvent extends TransportDataEvent { + public static readonly namespace: string = "transport.datawalletSynchronized"; + + public constructor(eventTargetAddress: string) { + super(DatawalletSynchronizedEvent.namespace, eventTargetAddress, undefined); + } +} diff --git a/packages/transport/src/events/index.ts b/packages/transport/src/events/index.ts index c3318f1a4..80fec200c 100644 --- a/packages/transport/src/events/index.ts +++ b/packages/transport/src/events/index.ts @@ -1,3 +1,4 @@ +export * from "./DatawalletSynchronizedEvent"; export * from "./IdentityDeletionProcessStatusChangedEvent"; export * from "./MessageDeliveredEvent"; export * from "./MessageReceivedEvent"; diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 40b2592cd..6dee85801 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -230,11 +230,9 @@ export class AccountController { } public async syncDatawallet(force = false): Promise { - if (!force && !this.autoSync) { - return; - } + if (!force && !this.autoSync) return; - return await this.synchronization.sync("OnlyDatawallet"); + await this.synchronization.sync("OnlyDatawallet"); } public async syncEverything(): Promise { diff --git a/packages/transport/src/modules/sync/SyncController.ts b/packages/transport/src/modules/sync/SyncController.ts index 638bfce7d..7c2b01d74 100644 --- a/packages/transport/src/modules/sync/SyncController.ts +++ b/packages/transport/src/modules/sync/SyncController.ts @@ -3,6 +3,7 @@ import { log } from "@js-soft/ts-utils"; import { CoreDate, CoreError, CoreId } from "@nmshd/core-types"; import { ControllerName, RequestError, TransportController, TransportCoreErrors, TransportError, TransportLoggerFactory } from "../../core"; import { DependencyOverrides } from "../../core/DependencyOverrides"; +import { DatawalletSynchronizedEvent } from "../../events/DatawalletSynchronizedEvent"; import { AccountController } from "../accounts/AccountController"; import { ChangedItems } from "./ChangedItems"; import { DatawalletModificationMapper } from "./DatawalletModificationMapper"; @@ -99,6 +100,8 @@ export class SyncController extends TransportController { if (this.datawalletEnabled && (await this.unpushedDatawalletModifications.exists())) { await this.syncDatawallet().catch((e) => this.log.error(e)); } + + this.transport.eventBus.publish(new DatawalletSynchronizedEvent(this.parent.identity.address.toString())); } } From 4abbafe2dc5f342e5e7f2657c066b835d28b8e2d Mon Sep 17 00:00:00 2001 From: Sebastian Mahr Date: Thu, 28 Nov 2024 15:06:22 +0100 Subject: [PATCH 07/12] Provide backbone-defined public RelationshipTemplate references (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add public relationship references POC * chore: nomenclature * chore: update index * chore: update schema * chore: typo * chore: pr comments * chore: pr comments * chore: use barrels * test: add tests for new backbone version * test: add rest client mock * chore: add missing file * chore: fix eslint * chore: fix eslint * chore: test naming * chore: use standard mock strings * Update packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * chore: switch to mockito * chore: remove unused file * Update packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts Co-authored-by: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> * chore: simpler naming * chore: simplify / order tests --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Milena Czierlinski <146972016+Milena-Czierlinski@users.noreply.github.com> Co-authored-by: Julian König --- package-lock.json | 5 +- packages/runtime/package.json | 7 +- packages/runtime/src/Runtime.ts | 5 + .../src/extensibility/TransportServices.ts | 4 +- ...licRelationshipTemplateReferencesFacade.ts | 12 + .../extensibility/facades/transport/index.ts | 3 +- .../PublicRelationshipTemplateReferenceDTO.ts | 5 + packages/runtime/src/types/transport/index.ts | 3 +- .../runtime/src/useCases/common/Schemas.ts | 732 +++++++++--------- .../runtime/src/useCases/transport/index.ts | 3 +- ...GetPublicRelationshipTemplateReferences.ts | 19 + ...blicRelationshipTemplateReferenceMapper.ts | 18 + .../index.ts | 1 + ...blicRelationshipTemplateReferences.test.ts | 38 + .../transport/src/core/TransportController.ts | 1 + .../src/modules/accounts/AccountController.ts | 3 + packages/transport/src/modules/index.ts | 3 + ...elationshipTemplateReferencesController.ts | 34 + ...blicRelationshipTemplateReferenceClient.ts | 14 + .../PublicRelationshipTemplateReference.ts | 15 + ...onshipTemplateReferencesController.test.ts | 61 ++ 21 files changed, 612 insertions(+), 374 deletions(-) create mode 100644 packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts create mode 100644 packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts create mode 100644 packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts create mode 100644 packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts create mode 100644 packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts create mode 100644 packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts diff --git a/package-lock.json b/package-lock.json index 53c1dfa65..1354fe12c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8934,6 +8934,8 @@ }, "node_modules/ts-mockito": { "version": "2.6.1", + "resolved": "https://registry.npmjs.org/ts-mockito/-/ts-mockito-2.6.1.tgz", + "integrity": "sha512-qU9m/oEBQrKq5hwfbJ7MgmVN5Gu6lFnIGWvpxSjrqq6YYEVv+RwVFWySbZMBgazsWqv6ctAyVBpo9TmAxnOEKw==", "dev": true, "license": "MIT", "dependencies": { @@ -9633,7 +9635,8 @@ "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "ts-json-schema-generator": "2.3.0" + "ts-json-schema-generator": "2.3.0", + "ts-mockito": "^2.6.1" } }, "packages/runtime/node_modules/ajv-errors": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 376cee3a2..9737ff10a 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -70,6 +70,7 @@ "@nmshd/crypto": "2.1.0", "@nmshd/iql": "^1.0.2", "@nmshd/transport": "*", + "@nmshd/typescript-ioc": "3.2.4", "ajv": "^8.17.1", "ajv-errors": "^3.0.0", "ajv-formats": "^3.0.1", @@ -78,8 +79,7 @@ "luxon": "^3.5.0", "qrcode": "1.5.4", "reflect-metadata": "^0.2.2", - "ts-simple-nameof": "^1.3.1", - "@nmshd/typescript-ioc": "3.2.4" + "ts-simple-nameof": "^1.3.1" }, "devDependencies": { "@js-soft/docdb-access-loki": "1.1.0", @@ -89,7 +89,8 @@ "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", - "ts-json-schema-generator": "2.3.0" + "ts-json-schema-generator": "2.3.0", + "ts-mockito": "^2.6.1" }, "publishConfig": { "access": "public", diff --git a/packages/runtime/src/Runtime.ts b/packages/runtime/src/Runtime.ts index 6f2faa8e7..4ebfe50fa 100644 --- a/packages/runtime/src/Runtime.ts +++ b/packages/runtime/src/Runtime.ts @@ -25,6 +25,7 @@ import { IdentityController, IdentityDeletionProcessController, MessageController, + PublicRelationshipTemplateReferencesController, RelationshipsController, RelationshipTemplateController, TokenController, @@ -258,6 +259,10 @@ export abstract class Runtime { .factory(() => this.getAccountController().tokens) .scope(Scope.Request); + Container.bind(PublicRelationshipTemplateReferencesController) + .factory(() => this.getAccountController().publicRelationshipTemplateReferences) + .scope(Scope.Request); + Container.bind(ChallengeController) .factory(() => this.getAccountController().challenges) .scope(Scope.Request); diff --git a/packages/runtime/src/extensibility/TransportServices.ts b/packages/runtime/src/extensibility/TransportServices.ts index ecf2c97ac..32cdac56e 100644 --- a/packages/runtime/src/extensibility/TransportServices.ts +++ b/packages/runtime/src/extensibility/TransportServices.ts @@ -6,6 +6,7 @@ import { FilesFacade, IdentityDeletionProcessesFacade, MessagesFacade, + PublicRelationshipTemplateReferencesFacade, RelationshipsFacade, RelationshipTemplatesFacade, TokensFacade @@ -21,6 +22,7 @@ export class TransportServices { @Inject public readonly account: AccountFacade, @Inject public readonly devices: DevicesFacade, @Inject public readonly challenges: ChallengesFacade, - @Inject public readonly identityDeletionProcesses: IdentityDeletionProcessesFacade + @Inject public readonly identityDeletionProcesses: IdentityDeletionProcessesFacade, + @Inject public readonly publicRelationshipTemplateReferences: PublicRelationshipTemplateReferencesFacade ) {} } diff --git a/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts b/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts new file mode 100644 index 000000000..88351bf33 --- /dev/null +++ b/packages/runtime/src/extensibility/facades/transport/PublicRelationshipTemplateReferencesFacade.ts @@ -0,0 +1,12 @@ +import { Result } from "@js-soft/ts-utils"; +import { Inject } from "@nmshd/typescript-ioc"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types"; +import { GetPublicRelationshipTemplateReferencesUseCase } from "../../../useCases"; + +export class PublicRelationshipTemplateReferencesFacade { + public constructor(@Inject private readonly getPublicRelationshipTemplateReferencesUseCase: GetPublicRelationshipTemplateReferencesUseCase) {} + + public async getPublicRelationshipTemplateReferences(): Promise> { + return await this.getPublicRelationshipTemplateReferencesUseCase.execute(); + } +} diff --git a/packages/runtime/src/extensibility/facades/transport/index.ts b/packages/runtime/src/extensibility/facades/transport/index.ts index a14fc3df7..2f313be75 100644 --- a/packages/runtime/src/extensibility/facades/transport/index.ts +++ b/packages/runtime/src/extensibility/facades/transport/index.ts @@ -4,6 +4,7 @@ export * from "./DevicesFacade"; export * from "./FilesFacade"; export * from "./IdentityDeletionProcessesFacade"; export * from "./MessagesFacade"; -export * from "./RelationshipTemplatesFacade"; +export * from "./PublicRelationshipTemplateReferencesFacade"; export * from "./RelationshipsFacade"; +export * from "./RelationshipTemplatesFacade"; export * from "./TokensFacade"; diff --git a/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts b/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts new file mode 100644 index 000000000..9e90e73b4 --- /dev/null +++ b/packages/runtime/src/types/transport/PublicRelationshipTemplateReferenceDTO.ts @@ -0,0 +1,5 @@ +export interface PublicRelationshipTemplateReferenceDTO { + title: string; + description: string; + truncatedReference: string; +} diff --git a/packages/runtime/src/types/transport/index.ts b/packages/runtime/src/types/transport/index.ts index 2235cf6e4..c0e6f8064 100644 --- a/packages/runtime/src/types/transport/index.ts +++ b/packages/runtime/src/types/transport/index.ts @@ -2,10 +2,11 @@ export * from "./ChallengeDTO"; export * from "./DeviceDTO"; export * from "./DeviceOnboardingInfoDTO"; export * from "./FileDTO"; -export * from "./IdentityDTO"; export * from "./IdentityDeletionProcessDTO"; +export * from "./IdentityDTO"; export * from "./MessageDTO"; export * from "./MessageWithAttachmentsDTO"; +export * from "./PublicRelationshipTemplateReferenceDTO"; export * from "./RecipientDTO"; export * from "./RelationshipDTO"; export * from "./RelationshipTemplateDTO"; diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 178cd5b8f..10416a7a8 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -21998,70 +21998,63 @@ export const SendMessageRequest: any = { } } -export const CreateOwnRelationshipTemplateRequest: any = { +export const AcceptRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateOwnRelationshipTemplateRequest", + "$ref": "#/definitions/AcceptRelationshipRequest", "definitions": { - "CreateOwnRelationshipTemplateRequest": { + "AcceptRelationshipRequest": { "type": "object", "properties": { - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" - }, - "content": {}, - "maxNumberOfAllocations": { - "type": "number", - "minimum": 1 - }, - "forIdentity": { - "$ref": "#/definitions/AddressString" - }, - "passwordProtection": { - "type": "object", - "properties": { - "password": { - "type": "string", - "minLength": 1 - }, - "passwordIsPin": { - "type": "boolean", - "const": true - } - }, - "required": [ - "password" - ], - "additionalProperties": false + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ - "expiresAt", - "content" + "relationshipId" ], "additionalProperties": false }, - "ISO8601DateTimeString": { + "RelationshipIdString": { "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + "pattern": "REL[A-Za-z0-9]{17}" + } + } +} + +export const AcceptRelationshipReactivationRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AcceptRelationshipReactivationRequest", + "definitions": { + "AcceptRelationshipReactivationRequest": { + "type": "object", + "properties": { + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } + }, + "required": [ + "relationshipId" + ], + "additionalProperties": false }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateQRCodeForOwnTemplateRequest: any = { +export const CanCreateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateQRCodeForOwnTemplateRequest", + "$ref": "#/definitions/CanCreateRelationshipRequest", "definitions": { - "CreateQRCodeForOwnTemplateRequest": { + "CanCreateRelationshipRequest": { "type": "object", "properties": { "templateId": { "$ref": "#/definitions/RelationshipTemplateIdString" - } + }, + "creationContent": {} }, "required": [ "templateId" @@ -22075,94 +22068,93 @@ export const CreateQRCodeForOwnTemplateRequest: any = { } } -export const CreateTokenForOwnTemplateRequest: any = { +export const CreateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateTokenForOwnTemplateRequest", + "$ref": "#/definitions/CreateRelationshipRequest", "definitions": { - "CreateTokenForOwnTemplateRequest": { + "CreateRelationshipRequest": { "type": "object", "properties": { "templateId": { "$ref": "#/definitions/RelationshipTemplateIdString" }, - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" - }, - "ephemeral": { - "type": "boolean" - }, - "forIdentity": { - "$ref": "#/definitions/AddressString" - } + "creationContent": {} }, "required": [ - "templateId" + "templateId", + "creationContent" ], "additionalProperties": false }, "RelationshipTemplateIdString": { "type": "string", "pattern": "RLT[A-Za-z0-9]{17}" + } + } +} + +export const DecomposeRelationshipRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/DecomposeRelationshipRequest", + "definitions": { + "DecomposeRelationshipRequest": { + "type": "object", + "properties": { + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } + }, + "required": [ + "relationshipId" + ], + "additionalProperties": false }, - "ISO8601DateTimeString": { - "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" - }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateTokenQRCodeForOwnTemplateRequest: any = { +export const GetAttributesForRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateTokenQRCodeForOwnTemplateRequest", + "$ref": "#/definitions/GetAttributesForRelationshipRequest", "definitions": { - "CreateTokenQRCodeForOwnTemplateRequest": { + "GetAttributesForRelationshipRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" + "id": { + "$ref": "#/definitions/RelationshipIdString" }, - "expiresAt": { - "$ref": "#/definitions/ISO8601DateTimeString" + "hideTechnical": { + "type": "boolean" }, - "forIdentity": { - "$ref": "#/definitions/AddressString" + "onlyLatestVersions": { + "type": "boolean", + "description": "default: true" } }, "required": [ - "templateId" + "id" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { - "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" - }, - "ISO8601DateTimeString": { - "type": "string", - "errorMessage": "must match ISO8601 datetime format", - "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" - }, - "AddressString": { + "RelationshipIdString": { "type": "string", - "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const GetRelationshipTemplateRequest: any = { +export const GetRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipTemplateRequest", + "$ref": "#/definitions/GetRelationshipRequest", "definitions": { - "GetRelationshipTemplateRequest": { + "GetRelationshipRequest": { "type": "object", "properties": { "id": { - "$ref": "#/definitions/RelationshipTemplateIdString" + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ @@ -22170,72 +22162,53 @@ export const GetRelationshipTemplateRequest: any = { ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const GetRelationshipTemplatesRequest: any = { +export const GetRelationshipByAddressRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipTemplatesRequest", + "$ref": "#/definitions/GetRelationshipByAddressRequest", "definitions": { - "GetRelationshipTemplatesRequest": { + "GetRelationshipByAddressRequest": { + "type": "object", + "properties": { + "address": { + "$ref": "#/definitions/AddressString" + } + }, + "required": [ + "address" + ], + "additionalProperties": false + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + } + } +} + +export const GetRelationshipsRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipsRequest", + "definitions": { + "GetRelationshipsRequest": { "type": "object", "properties": { "query": { - "$ref": "#/definitions/GetRelationshipTemplatesQuery" - }, - "ownerRestriction": { - "$ref": "#/definitions/OwnerRestriction" + "$ref": "#/definitions/GetRelationshipsQuery" } }, "additionalProperties": false }, - "GetRelationshipTemplatesQuery": { + "GetRelationshipsQuery": { "type": "object", "properties": { - "isOwn": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "createdAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "expiresAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "createdBy": { + "peer": { "anyOf": [ { "type": "string" @@ -22248,7 +22221,7 @@ export const GetRelationshipTemplatesRequest: any = { } ] }, - "createdByDevice": { + "status": { "anyOf": [ { "type": "string" @@ -22261,20 +22234,7 @@ export const GetRelationshipTemplatesRequest: any = { } ] }, - "maxNumberOfAllocations": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "forIdentity": { + "template.id": { "anyOf": [ { "type": "string" @@ -22289,60 +22249,38 @@ export const GetRelationshipTemplatesRequest: any = { } }, "additionalProperties": false - }, - "OwnerRestriction": { - "type": "string", - "enum": [ - "o", - "p" - ] } } } -export const LoadPeerRelationshipTemplateRequest: any = { +export const RejectRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/LoadPeerRelationshipTemplateRequest", + "$ref": "#/definitions/RejectRelationshipRequest", "definitions": { - "LoadPeerRelationshipTemplateRequest": { + "RejectRelationshipRequest": { "type": "object", "properties": { - "reference": { - "anyOf": [ - { - "$ref": "#/definitions/TokenReferenceString" - }, - { - "$ref": "#/definitions/RelationshipTemplateReferenceString" - } - ] - }, - "password": { - "type": "string" + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" } }, "required": [ - "reference" + "relationshipId" ], - "additionalProperties": false, - "errorMessage": "token / relationship template reference invalid" - }, - "TokenReferenceString": { - "type": "string", - "pattern": "VE9L.{84}" + "additionalProperties": false }, - "RelationshipTemplateReferenceString": { + "RelationshipIdString": { "type": "string", - "pattern": "UkxU.{84}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const AcceptRelationshipRequest: any = { +export const RejectRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AcceptRelationshipRequest", + "$ref": "#/definitions/RejectRelationshipReactivationRequest", "definitions": { - "AcceptRelationshipRequest": { + "RejectRelationshipReactivationRequest": { "type": "object", "properties": { "relationshipId": { @@ -22361,11 +22299,11 @@ export const AcceptRelationshipRequest: any = { } } -export const AcceptRelationshipReactivationRequest: any = { +export const RequestRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/AcceptRelationshipReactivationRequest", + "$ref": "#/definitions/RequestRelationshipReactivationRequest", "definitions": { - "AcceptRelationshipReactivationRequest": { + "RequestRelationshipReactivationRequest": { "type": "object", "properties": { "relationshipId": { @@ -22384,60 +22322,57 @@ export const AcceptRelationshipReactivationRequest: any = { } } -export const CanCreateRelationshipRequest: any = { +export const RevokeRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CanCreateRelationshipRequest", + "$ref": "#/definitions/RevokeRelationshipRequest", "definitions": { - "CanCreateRelationshipRequest": { + "RevokeRelationshipRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" - }, - "creationContent": {} + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } }, "required": [ - "templateId" + "relationshipId" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const CreateRelationshipRequest: any = { +export const RevokeRelationshipReactivationRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/CreateRelationshipRequest", + "$ref": "#/definitions/RevokeRelationshipReactivationRequest", "definitions": { - "CreateRelationshipRequest": { + "RevokeRelationshipReactivationRequest": { "type": "object", "properties": { - "templateId": { - "$ref": "#/definitions/RelationshipTemplateIdString" - }, - "creationContent": {} + "relationshipId": { + "$ref": "#/definitions/RelationshipIdString" + } }, "required": [ - "templateId", - "creationContent" + "relationshipId" ], "additionalProperties": false }, - "RelationshipTemplateIdString": { + "RelationshipIdString": { "type": "string", - "pattern": "RLT[A-Za-z0-9]{17}" + "pattern": "REL[A-Za-z0-9]{17}" } } } -export const DecomposeRelationshipRequest: any = { +export const TerminateRelationshipRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/DecomposeRelationshipRequest", + "$ref": "#/definitions/TerminateRelationshipRequest", "definitions": { - "DecomposeRelationshipRequest": { + "TerminateRelationshipRequest": { "type": "object", "properties": { "relationshipId": { @@ -22456,75 +22391,117 @@ export const DecomposeRelationshipRequest: any = { } } -export const GetAttributesForRelationshipRequest: any = { +export const CreateOwnRelationshipTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetAttributesForRelationshipRequest", + "$ref": "#/definitions/CreateOwnRelationshipTemplateRequest", "definitions": { - "GetAttributesForRelationshipRequest": { + "CreateOwnRelationshipTemplateRequest": { "type": "object", "properties": { - "id": { - "$ref": "#/definitions/RelationshipIdString" + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" }, - "hideTechnical": { - "type": "boolean" + "content": {}, + "maxNumberOfAllocations": { + "type": "number", + "minimum": 1 }, - "onlyLatestVersions": { - "type": "boolean", - "description": "default: true" + "forIdentity": { + "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ - "id" + "expiresAt", + "content" ], "additionalProperties": false }, - "RelationshipIdString": { + "ISO8601DateTimeString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" } } } -export const GetRelationshipRequest: any = { +export const CreateQRCodeForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipRequest", + "$ref": "#/definitions/CreateQRCodeForOwnTemplateRequest", "definitions": { - "GetRelationshipRequest": { + "CreateQRCodeForOwnTemplateRequest": { "type": "object", "properties": { - "id": { - "$ref": "#/definitions/RelationshipIdString" + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" } }, "required": [ - "id" + "templateId" ], "additionalProperties": false }, - "RelationshipIdString": { + "RelationshipTemplateIdString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "pattern": "RLT[A-Za-z0-9]{17}" } } } -export const GetRelationshipByAddressRequest: any = { +export const CreateTokenForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipByAddressRequest", + "$ref": "#/definitions/CreateTokenForOwnTemplateRequest", "definitions": { - "GetRelationshipByAddressRequest": { + "CreateTokenForOwnTemplateRequest": { "type": "object", "properties": { - "address": { + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" + }, + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" + }, + "ephemeral": { + "type": "boolean" + }, + "forIdentity": { "$ref": "#/definitions/AddressString" } }, "required": [ - "address" + "templateId" ], "additionalProperties": false }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + }, + "ISO8601DateTimeString": { + "type": "string", + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, "AddressString": { "type": "string", "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" @@ -22532,23 +22509,87 @@ export const GetRelationshipByAddressRequest: any = { } } -export const GetRelationshipsRequest: any = { +export const CreateTokenQRCodeForOwnTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/GetRelationshipsRequest", + "$ref": "#/definitions/CreateTokenQRCodeForOwnTemplateRequest", "definitions": { - "GetRelationshipsRequest": { + "CreateTokenQRCodeForOwnTemplateRequest": { + "type": "object", + "properties": { + "templateId": { + "$ref": "#/definitions/RelationshipTemplateIdString" + }, + "expiresAt": { + "$ref": "#/definitions/ISO8601DateTimeString" + }, + "forIdentity": { + "$ref": "#/definitions/AddressString" + } + }, + "required": [ + "templateId" + ], + "additionalProperties": false + }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + }, + "ISO8601DateTimeString": { + "type": "string", + "errorMessage": "must match ISO8601 datetime format", + "pattern": "^([+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24:?00)([.,]\\d+(?!:))?)?(\\17[0-5]\\d([.,]\\d+)?)?([zZ]|([+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$" + }, + "AddressString": { + "type": "string", + "pattern": "did:e:((([A-Za-z0-9]+(-[A-Za-z0-9]+)*)\\.)+[a-z]{2,}|localhost):dids:[0-9a-f]{22}" + } + } +} + +export const GetRelationshipTemplateRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipTemplateRequest", + "definitions": { + "GetRelationshipTemplateRequest": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/RelationshipTemplateIdString" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "RelationshipTemplateIdString": { + "type": "string", + "pattern": "RLT[A-Za-z0-9]{17}" + } + } +} + +export const GetRelationshipTemplatesRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/GetRelationshipTemplatesRequest", + "definitions": { + "GetRelationshipTemplatesRequest": { "type": "object", "properties": { "query": { - "$ref": "#/definitions/GetRelationshipsQuery" + "$ref": "#/definitions/GetRelationshipTemplatesQuery" + }, + "ownerRestriction": { + "$ref": "#/definitions/OwnerRestriction" } }, "additionalProperties": false }, - "GetRelationshipsQuery": { + "GetRelationshipTemplatesQuery": { "type": "object", "properties": { - "peer": { + "isOwn": { "anyOf": [ { "type": "string" @@ -22561,7 +22602,7 @@ export const GetRelationshipsRequest: any = { } ] }, - "status": { + "createdAt": { "anyOf": [ { "type": "string" @@ -22574,7 +22615,59 @@ export const GetRelationshipsRequest: any = { } ] }, - "template.id": { + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "createdBy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "createdByDevice": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "maxNumberOfAllocations": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "forIdentity": { "anyOf": [ { "type": "string" @@ -22589,144 +22682,51 @@ export const GetRelationshipsRequest: any = { } }, "additionalProperties": false - } - } -} - -export const RejectRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RejectRelationshipRequest", - "definitions": { - "RejectRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RejectRelationshipReactivationRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RejectRelationshipReactivationRequest", - "definitions": { - "RejectRelationshipReactivationRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RequestRelationshipReactivationRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RequestRelationshipReactivationRequest", - "definitions": { - "RequestRelationshipReactivationRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false - }, - "RelationshipIdString": { - "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const RevokeRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RevokeRelationshipRequest", - "definitions": { - "RevokeRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false }, - "RelationshipIdString": { + "OwnerRestriction": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "enum": [ + "o", + "p" + ] } } } -export const RevokeRelationshipReactivationRequest: any = { +export const LoadPeerRelationshipTemplateRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/RevokeRelationshipReactivationRequest", + "$ref": "#/definitions/LoadPeerRelationshipTemplateRequest", "definitions": { - "RevokeRelationshipReactivationRequest": { + "LoadPeerRelationshipTemplateRequest": { "type": "object", "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" + "reference": { + "anyOf": [ + { + "$ref": "#/definitions/TokenReferenceString" + }, + { + "$ref": "#/definitions/RelationshipTemplateReferenceString" + } + ] + }, + "password": { + "type": "string" } }, "required": [ - "relationshipId" + "reference" ], - "additionalProperties": false + "additionalProperties": false, + "errorMessage": "token / relationship template reference invalid" }, - "RelationshipIdString": { + "TokenReferenceString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" - } - } -} - -export const TerminateRelationshipRequest: any = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/TerminateRelationshipRequest", - "definitions": { - "TerminateRelationshipRequest": { - "type": "object", - "properties": { - "relationshipId": { - "$ref": "#/definitions/RelationshipIdString" - } - }, - "required": [ - "relationshipId" - ], - "additionalProperties": false + "pattern": "VE9L.{84}" }, - "RelationshipIdString": { + "RelationshipTemplateReferenceString": { "type": "string", - "pattern": "REL[A-Za-z0-9]{17}" + "pattern": "UkxU.{84}" } } } diff --git a/packages/runtime/src/useCases/transport/index.ts b/packages/runtime/src/useCases/transport/index.ts index 0bce894c3..51425e014 100644 --- a/packages/runtime/src/useCases/transport/index.ts +++ b/packages/runtime/src/useCases/transport/index.ts @@ -4,6 +4,7 @@ export * from "./devices"; export * from "./files"; export * from "./identityDeletionProcesses"; export * from "./messages"; -export * from "./relationshipTemplates"; +export * from "./publicRelationshipTemplateReferences"; export * from "./relationships"; +export * from "./relationshipTemplates"; export * from "./tokens"; diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts new file mode 100644 index 000000000..5699b8d5f --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/GetPublicRelationshipTemplateReferences.ts @@ -0,0 +1,19 @@ +import { Result } from "@js-soft/ts-utils"; +import { PublicRelationshipTemplateReferencesController } from "@nmshd/transport"; +import { Inject } from "@nmshd/typescript-ioc"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types"; +import { UseCase } from "../../common"; +import { PublicRelationshipTemplateReferenceMapper } from "./PublicRelationshipTemplateReferenceMapper"; + +export class GetPublicRelationshipTemplateReferencesUseCase extends UseCase { + public constructor(@Inject private readonly publicRelationshipTemplateReferencesController: PublicRelationshipTemplateReferencesController) { + super(); + } + + protected async executeInternal(): Promise> { + const publicRelationshipTemplateReferences = await this.publicRelationshipTemplateReferencesController.getPublicRelationshipTemplateReferences(); + const templateReferences = PublicRelationshipTemplateReferenceMapper.toPublicRelationshipTemplateReferenceDTOList(publicRelationshipTemplateReferences); + + return Result.ok(templateReferences); + } +} diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts new file mode 100644 index 000000000..4194f6306 --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferenceMapper.ts @@ -0,0 +1,18 @@ +import { PublicRelationshipTemplateReference } from "@nmshd/transport"; +import { PublicRelationshipTemplateReferenceDTO } from "../../../types/transport/PublicRelationshipTemplateReferenceDTO"; + +export class PublicRelationshipTemplateReferenceMapper { + public static toPublicRelationshipTemplateReferenceDTO(publicRelationshipTemplateReference: PublicRelationshipTemplateReference): PublicRelationshipTemplateReferenceDTO { + return { + title: publicRelationshipTemplateReference.title, + description: publicRelationshipTemplateReference.description, + truncatedReference: publicRelationshipTemplateReference.truncatedReference + }; + } + + public static toPublicRelationshipTemplateReferenceDTOList( + publicRelationshipTemplateReferences: PublicRelationshipTemplateReference[] + ): PublicRelationshipTemplateReferenceDTO[] { + return publicRelationshipTemplateReferences.map((reference) => this.toPublicRelationshipTemplateReferenceDTO(reference)); + } +} diff --git a/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts new file mode 100644 index 000000000..5aedb3201 --- /dev/null +++ b/packages/runtime/src/useCases/transport/publicRelationshipTemplateReferences/index.ts @@ -0,0 +1 @@ +export * from "./GetPublicRelationshipTemplateReferences"; diff --git a/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts b/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts new file mode 100644 index 000000000..b9829087c --- /dev/null +++ b/packages/runtime/test/transport/publicRelationshipTemplateReferences.test.ts @@ -0,0 +1,38 @@ +import { ClientResult, PublicRelationshipTemplateReferenceClient } from "@nmshd/transport"; +import { reset, spy, when } from "ts-mockito"; +import { RuntimeServiceProvider, TestRuntimeServices } from "../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices: TestRuntimeServices; +let mockClient: PublicRelationshipTemplateReferenceClient; + +beforeAll(async () => { + runtimeServices = (await serviceProvider.launch(1))[0]; + const client = runtimeServices.transport.publicRelationshipTemplateReferences["getPublicRelationshipTemplateReferencesUseCase"][ + "publicRelationshipTemplateReferencesController" + ]["client"] as PublicRelationshipTemplateReferenceClient; + + mockClient = spy(client); +}, 30000); + +afterAll(() => serviceProvider.stop()); + +afterEach(() => reset(mockClient)); + +describe("PublicRelationshipTemplateReferences", () => { + test("should read the PublicRelationshipTemplateReferences", async () => { + const mockResponse = [ + { + title: "aTitle", + description: "aDescription", + truncatedReference: "aReference" + } + ]; + + when(mockClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok(mockResponse)); + + const publicRelationshipTemplateReferences = await runtimeServices.transport.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplateReferences.value).toStrictEqual(mockResponse); + }); +}); diff --git a/packages/transport/src/core/TransportController.ts b/packages/transport/src/core/TransportController.ts index 05e549fa1..3743cf316 100644 --- a/packages/transport/src/core/TransportController.ts +++ b/packages/transport/src/core/TransportController.ts @@ -20,6 +20,7 @@ export enum ControllerName { File = "File", Identity = "Identity", Message = "Message", + PublicRelationshipTemplateReferences = "PublicRelationshipTemplateReferences", Relationship = "Relationship", Relationships = "Relationships", RelationshipTemplate = "RelationshipTemplate", diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 6dee85801..96e761ea4 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -23,6 +23,7 @@ import { DeviceSecretCredentials } from "../devices/local/DeviceSecretCredential import { DeviceSharedSecret } from "../devices/transmission/DeviceSharedSecret"; import { FileController } from "../files/FileController"; import { MessageController } from "../messages/MessageController"; +import { PublicRelationshipTemplateReferencesController } from "../publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController"; import { RelationshipTemplateController } from "../relationshipTemplates/RelationshipTemplateController"; import { RelationshipSecretController } from "../relationships/RelationshipSecretController"; import { RelationshipsController } from "../relationships/RelationshipsController"; @@ -59,6 +60,7 @@ export class AccountController { public devices: DevicesController; public files: FileController; public messages: MessageController; + public publicRelationshipTemplateReferences: PublicRelationshipTemplateReferencesController; public relationships: RelationshipsController; public relationshipTemplates: RelationshipTemplateController; private synchronization: SyncController; @@ -213,6 +215,7 @@ export class AccountController { this.relationshipTemplates = await new RelationshipTemplateController(this, this.relationshipSecrets).init(); this.messages = await new MessageController(this).init(); this.tokens = await new TokenController(this).init(); + this.publicRelationshipTemplateReferences = await new PublicRelationshipTemplateReferencesController(this).init(); this.synchronization = await new SyncController(this, this.dependencyOverrides, this.unpushedDatawalletModifications, this.config.datawalletEnabled).init(); diff --git a/packages/transport/src/modules/index.ts b/packages/transport/src/modules/index.ts index cefb1bab6..5d7c511a6 100644 --- a/packages/transport/src/modules/index.ts +++ b/packages/transport/src/modules/index.ts @@ -65,6 +65,9 @@ export * from "./messages/transmission/MessageEnvelope"; export * from "./messages/transmission/MessageEnvelopeRecipient"; export * from "./messages/transmission/MessageSignature"; export * from "./messages/transmission/MessageSigned"; +export * from "./publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient"; +export * from "./publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference"; +export * from "./publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController"; export * from "./relationships/backbone/BackboneGetRelationships"; export * from "./relationships/backbone/BackbonePostRelationship"; export * from "./relationships/backbone/RelationshipClient"; diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts new file mode 100644 index 000000000..635c15e36 --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.ts @@ -0,0 +1,34 @@ +import { RequestError } from "../../core/backbone/RequestError"; +import { ControllerName, TransportController } from "../../core/TransportController"; +import { AccountController } from "../accounts/AccountController"; +import { PublicRelationshipTemplateReferenceClient } from "./backbone/PublicRelationshipTemplateReferenceClient"; +import { PublicRelationshipTemplateReference } from "./data/PublicRelationshipTemplateReference"; + +export class PublicRelationshipTemplateReferencesController extends TransportController { + public constructor(parent: AccountController) { + super(ControllerName.PublicRelationshipTemplateReferences, parent); + } + + private client: PublicRelationshipTemplateReferenceClient; + + public override async init(): Promise { + await super.init(); + + this.client = new PublicRelationshipTemplateReferenceClient(this.config, this.parent.authenticator, this.transport.correlator); + + return this; + } + + public async getPublicRelationshipTemplateReferences(): Promise { + try { + const result = await this.client.getPublicRelationshipTemplateReferences(); + + const references = result.value.map((reference) => PublicRelationshipTemplateReference.fromAny(reference)); + return references; + } catch (e) { + if (e instanceof RequestError && e.status === 404) return []; + + throw e; + } + } +} diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts new file mode 100644 index 000000000..d048413cb --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/backbone/PublicRelationshipTemplateReferenceClient.ts @@ -0,0 +1,14 @@ +import { ClientResult } from "../../../core/backbone/ClientResult"; +import { RESTClientAuthenticate } from "../../../core/backbone/RESTClientAuthenticate"; + +export interface BackbonePublicRelationshipTemplateReference { + title: string; + description: string; + truncatedReference: string; +} + +export class PublicRelationshipTemplateReferenceClient extends RESTClientAuthenticate { + public async getPublicRelationshipTemplateReferences(): Promise> { + return await this.get("/api/poc/PublicRelationshipTemplateReferences"); + } +} diff --git a/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts b/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts new file mode 100644 index 000000000..10c53b4be --- /dev/null +++ b/packages/transport/src/modules/publicRelationshipTemplateReferences/data/PublicRelationshipTemplateReference.ts @@ -0,0 +1,15 @@ +import { Serializable, serialize, validate } from "@js-soft/ts-serval"; + +export class PublicRelationshipTemplateReference extends Serializable { + @serialize() + @validate() + public title: string; + + @serialize() + @validate() + public description: string; + + @serialize() + @validate() + public truncatedReference: string; +} diff --git a/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts b/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts new file mode 100644 index 000000000..536a8e1c9 --- /dev/null +++ b/packages/transport/test/modules/publicRelationshipTemplateReferences/PublicRelationshipTemplateReferencesController.test.ts @@ -0,0 +1,61 @@ +import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; +import { reset, spy, when } from "ts-mockito"; +import { AccountController, ClientResult, PublicRelationshipTemplateReferenceClient, RequestError, Transport } from "../../../src"; +import { TestUtil } from "../../testHelpers/TestUtil"; + +let connection: IDatabaseConnection; + +let transport: Transport; +let account: AccountController; +let mockedClient: PublicRelationshipTemplateReferenceClient; + +beforeAll(async function () { + connection = await TestUtil.createDatabaseConnection(); + transport = TestUtil.createTransport(connection); + + await transport.init(); + + const accounts = await TestUtil.provideAccounts(transport, 1); + + account = accounts[0]; + + const client = account.publicRelationshipTemplateReferences["client"]; + mockedClient = spy(client); +}); + +afterAll(async () => { + await account.close(); + + await connection.close(); +}); + +afterEach(() => reset(mockedClient)); + +describe("PublicRelationshipTemplateReferencesController", () => { + test("should return the backbone defined PublicRelationshipTemplateReferences", async () => { + const mockResponse = [{ title: "aTitle", description: "aDescription", truncatedReference: "aReference" }]; + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok(mockResponse)); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates.map((reference) => reference.toJSON())).toStrictEqual(mockResponse); + }); + + test("should return an empty array if the backbone endpoint returns an empty array", async () => { + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve(ClientResult.ok([])); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates).toStrictEqual([]); + }); + + test("should return an empty array if the backbone endpoint is not available", async () => { + when(mockedClient.getPublicRelationshipTemplateReferences()).thenResolve( + ClientResult.fail(new RequestError("some method", "some path", undefined, undefined, undefined, undefined, 404)) + ); + + const publicRelationshipTemplates = await account.publicRelationshipTemplateReferences.getPublicRelationshipTemplateReferences(); + + expect(publicRelationshipTemplates).toHaveLength(0); + }); +}); From 2703c38a915082184c6730ac66c1a9e47bf06f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:53:12 +0100 Subject: [PATCH 08/12] Configure GitHub repo using settings file (#349) --- .github/settings.yaml | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/settings.yaml diff --git a/.github/settings.yaml b/.github/settings.yaml new file mode 100644 index 000000000..650601a8b --- /dev/null +++ b/.github/settings.yaml @@ -0,0 +1,42 @@ +--- +# https://github.com/repository-settings/app + +repository: + allow_squash_merge: true + allow_merge_commit: false + allow_rebase_merge: false + allow_auto_merge: true + allow_update_branch: true + delete_branch_on_merge: true + +labels: + - name: breaking-change + color: "#16060F" + description: A breaking change + - name: bug + color: "#d73a4a" + description: Something isn't working + - name: chore + color: "#c2e0c6" + description: Some routine work like updating dependencies + - name: ci + color: "#DFB5FD" + description: Continuous Integration related stuff + - name: dependencies + color: "#0366d6" + description: Pull requests that update dependencies + - name: documentation + color: "#0075ca" + description: Improvements or additions to documentation + - name: enhancement + color: "#a2eeef" + description: New feature or request + - name: refactoring + color: "#880361" + description: Refactoring of code + - name: test + color: "#20D89D" + description: This pull request contains only new or changed tests + - name: wip + color: "#32BF4C" + description: Work in Progress (blocks mergify from auto update the branch) From 1d72478f2038cf9161ab24995bbc294244a943f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:55:35 +0100 Subject: [PATCH 09/12] Change settings file ending (#350) * chore: rm yaml * chore: add yml --- .github/{settings.yaml => settings.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{settings.yaml => settings.yml} (100%) diff --git a/.github/settings.yaml b/.github/settings.yml similarity index 100% rename from .github/settings.yaml rename to .github/settings.yml From 3710be4de2fd342c22cd253e828ae43b98cef40e Mon Sep 17 00:00:00 2001 From: Sebastian Mahr Date: Fri, 29 Nov 2024 13:19:07 +0100 Subject: [PATCH 10/12] test: nagtive test of Adress validation fix (#351) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- packages/transport/test/utils/IdentityGenerator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transport/test/utils/IdentityGenerator.test.ts b/packages/transport/test/utils/IdentityGenerator.test.ts index d9e1536ca..c8d8626db 100644 --- a/packages/transport/test/utils/IdentityGenerator.test.ts +++ b/packages/transport/test/utils/IdentityGenerator.test.ts @@ -120,7 +120,7 @@ describe("IdentityGeneratorTest", function () { test("should negatively check an incorrect address object (wrong checksum)", async function () { const address = await IdentityUtil.createAddress(kp.publicKey, "example.com"); - const index = 5; + const index = 32; let replaceWith = "b"; const currentString = address.address.substr(index, replaceWith.length); if (currentString === replaceWith) { From 61a888294f9c5bff16333ee883f616d07b440c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:03:00 +0100 Subject: [PATCH 11/12] Bring `getAccountsInDeletion` and `getAccountsNotInDeletion` to `AccountServices` (#352) * feat: add getAccountsInDeletion and getAccountsNotInDeletion to AccountServices * refactor: simplify getAccounts* methods * chore: test another method --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../src/multiAccount/AccountServices.ts | 10 ++++++++++ .../src/multiAccount/MultiAccountController.ts | 16 ++++++++-------- .../multiAccount/MultiAccountController.test.ts | 10 ++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/app-runtime/src/multiAccount/AccountServices.ts b/packages/app-runtime/src/multiAccount/AccountServices.ts index c292161ab..d71cf384c 100644 --- a/packages/app-runtime/src/multiAccount/AccountServices.ts +++ b/packages/app-runtime/src/multiAccount/AccountServices.ts @@ -23,6 +23,16 @@ export class AccountServices { return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); } + public async getAccountsInDeletion(): Promise { + const localAccounts = await this.multiAccountController.getAccountsInDeletion(); + return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); + } + + public async getAccountsNotInDeletion(): Promise { + const localAccounts = await this.multiAccountController.getAccountsNotInDeletion(); + return localAccounts.map((account) => LocalAccountMapper.toLocalAccountDTO(account)); + } + public async getAccount(id: string): Promise { const localAccount = await this.multiAccountController.getAccount(CoreId.from(id)); return LocalAccountMapper.toLocalAccountDTO(localAccount); diff --git a/packages/app-runtime/src/multiAccount/MultiAccountController.ts b/packages/app-runtime/src/multiAccount/MultiAccountController.ts index 252590aa1..08ce2db46 100644 --- a/packages/app-runtime/src/multiAccount/MultiAccountController.ts +++ b/packages/app-runtime/src/multiAccount/MultiAccountController.ts @@ -78,20 +78,20 @@ export class MultiAccountController { } public async getAccounts(): Promise { - const dbAccounts = await this._localAccounts.list(); - return dbAccounts.map((account) => LocalAccount.from(account)); + return await this._findAccounts(); } public async getAccountsInDeletion(): Promise { - const allAccounts = await this.getAccounts(); - const accountsInDeletion = allAccounts.filter((item) => item.deletionDate !== undefined); - return accountsInDeletion; + return await this._findAccounts({ deletionDate: { $exists: true } }); } public async getAccountsNotInDeletion(): Promise { - const allAccounts = await this.getAccounts(); - const accountsNotInDeletion = allAccounts.filter((item) => item.deletionDate === undefined); - return accountsNotInDeletion; + return await this._findAccounts({ deletionDate: { $exists: false } }); + } + + private async _findAccounts(query?: any) { + const accounts = await this._localAccounts.find(query); + return accounts.map((account) => LocalAccount.from(account)); } public async selectAccount(id: CoreId): Promise<[LocalAccount, AccountController]> { diff --git a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts index 22863881b..3cbf0a8fc 100644 --- a/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts +++ b/packages/app-runtime/test/multiAccount/MultiAccountController.test.ts @@ -41,6 +41,16 @@ describe("MultiAccountController", function () { afterAll(async () => await runtime.stop()); + test("should get all accounts", async function () { + const accounts = await runtime.multiAccountController.getAccounts(); + expect(accounts).toHaveLength(3); + + const addresses = accounts.map((account) => account.address!.toString()); + expect(addresses).toContain(account1.address); + expect(addresses).toContain(account2.address); + expect(addresses).toContain(account3.address); + }); + test("should get all accounts in deletion", async function () { await session1.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); await session2.transportServices.identityDeletionProcesses.initiateIdentityDeletionProcess(); From 2241e42f87b85a5df82f566d108b81347f93048f Mon Sep 17 00:00:00 2001 From: Magnus Kuhn <127854942+Magnus-Kuhn@users.noreply.github.com> Date: Fri, 29 Nov 2024 14:23:10 +0100 Subject: [PATCH 12/12] Password-protected Tokens (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add password protection to templates * test: add tests * fix/test: add tests, make fixes * fix: backbone API * feat: change error message * refactor: nameof, toString * feat/test: error message, add validation test * fix: remove only * chore: bump backbone Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> * fix: missing password pass * refactor: wrong variable name * refactor: review comments * chore: upgrade backbone and adapt client * feat: add password-protection to tokens * test: add anonymous tests * fix: pass password * fix: error in test * chore: build schemas * feat: hash passwords * feat: add separate pin * feat: enhance password type * refactor: align error messages * fix: more enhancing password type * test: adapt tests * test: reference adaptations * wip * refactor/feat: review comments * feat: add transport empty string validation * test: add tests * fix: schemas, error codes * fix: add PINs when loading * feat: add loading validation, tests * test: add validations * fix: test errors * fix/feat: add loading validation, fix tests * test: fix tests * refactor: no PIN validation in loading schema * test: fix copy-paste error * refactor: use schemas for empty string validation * feat: use salt * refactor/test: reference adaptations * feat: adapt automatic version setting * fix: version in reference * feat: remove salt from dto * test: refactor tests * feat: add/adapt salt validation * refactor/test: validations * test: fix ids * chore: bump backbone * feat: remove version * test: fix salt test * chore: transport PR comments * chore: runtime PR comments * fix/refactor: more stuff * test: correct check * test: fix used function * feat: add transport setting validation * refactor: import * chore: build schemas * refactor: passwordinfo * fix: cleanup * test: cleanup * refactor/fix: use password info derivatives * refactor: remove unused error * test: fix error names in tests * feat: password error message * refactor: test names and content, class usage * feat: runtime interface with flag * test: adapt tests * chore: schemaas * fix: mapping, tests * refactor: simplify object access * refactor: rename passwordProtection * refactor: naming * chore: move business logic to object * refactor: use min * fix: tests * feat: passwordIsPin true or undefined * chore: build schemas * chore: remove unused method * test: fix tests * fix: ability to truncate * refactor: add fromTruncted * feat: adapt tokens to templates * feat: error message mentions wrong password * fix/feat: add missing validations, type cleanup * test: adapt token controller tests * test: adapt runtime tests * chore: schemas * fix: tests, loading token * fix: tests, inheritance * refactor: move expiresAt to schema validator * refactor: move passwordProtection into schema validator * fix: use lodash * refactor: line breaks, comments, lodash * fix: validator * test: protect token * test: remove redundant tests * test: add reference test * chore: build schemas * test: fix error messages * test: fix error code * test: fix error messages * chore: bump backbone * fix: missing password * refactor: namings, restructurings * refactor: generic input validator with password only * refactor: separate test files * test: test names, some fixes * refactor: tokenAndTemplateCreationValidator * refactor: improve tokenAndTemplateCreationValidator * chore: build schemas * refactor: invalidPropertyValue * test: adapt tests --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Julian König <33655937+jkoenig134@users.noreply.github.com> Co-authored-by: Julian König --- .dev/compose.backbone.env | 2 +- .../runtime/src/types/transport/TokenDTO.ts | 4 + .../tokens/LoadPeerTokenAnonymous.ts | 3 +- .../src/useCases/common/RuntimeErrors.ts | 11 +- .../runtime/src/useCases/common/Schemas.ts | 97 +++++ packages/runtime/src/useCases/common/index.ts | 1 + .../TokenAndTemplateCreationValidator.ts | 48 ++ .../account/LoadItemFromTruncatedReference.ts | 11 +- .../transport/files/CreateTokenForFile.ts | 16 +- .../files/CreateTokenQRCodeForFile.ts | 16 +- .../useCases/transport/files/GetOrLoadFile.ts | 11 +- .../CreateOwnRelationshipTemplate.ts | 28 +- .../CreateTokenForOwnRelationshipTemplate.ts | 19 +- ...teTokenQRCodeForOwnRelationshipTemplate.ts | 36 +- .../LoadPeerRelationshipTemplate.ts | 2 +- .../transport/tokens/CreateOwnToken.ts | 25 +- .../transport/tokens/LoadPeerToken.ts | 3 +- .../useCases/transport/tokens/TokenMapper.ts | 8 +- .../runtime/test/anonymous/tokens.test.ts | 33 ++ packages/runtime/test/lib/testUtils.ts | 21 +- .../passwordProtection/files.test.ts | 120 +++++ .../relationshipTemplates.test.ts | 410 ++++++++++++++++++ .../passwordProtection/tokens.test.ts | 108 +++++ .../transport/relationshipTemplates.test.ts | 187 -------- .../src/core/types/PasswordProtection.ts | 12 + .../tokens/AnonymousTokenController.ts | 23 +- .../src/modules/tokens/TokenController.ts | 106 ++++- .../tokens/backbone/AnonymousTokenClient.ts | 5 +- .../tokens/backbone/BackboneGetTokens.ts | 2 +- .../tokens/backbone/BackbonePostTokens.ts | 1 + .../modules/tokens/backbone/TokenClient.ts | 5 +- .../tokens/local/SendTokenParameters.ts | 6 + .../src/modules/tokens/local/Token.ts | 15 +- .../tokens/AnonymousTokenController.test.ts | 39 ++ .../modules/tokens/TokenController.test.ts | 75 ++++ 35 files changed, 1219 insertions(+), 290 deletions(-) create mode 100644 packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts create mode 100644 packages/runtime/test/transport/passwordProtection/files.test.ts create mode 100644 packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts create mode 100644 packages/runtime/test/transport/passwordProtection/tokens.test.ts diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index a3a499d00..3855dea43 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.19.1 +BACKBONE_VERSION=6.20.0 diff --git a/packages/runtime/src/types/transport/TokenDTO.ts b/packages/runtime/src/types/transport/TokenDTO.ts index 953b7e570..b2a68b468 100644 --- a/packages/runtime/src/types/transport/TokenDTO.ts +++ b/packages/runtime/src/types/transport/TokenDTO.ts @@ -6,6 +6,10 @@ export interface TokenDTO { createdAt: string; expiresAt: string; forIdentity?: string; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; truncatedReference: string; isEphemeral: boolean; } diff --git a/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts b/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts index 82d4b9990..84a881d02 100644 --- a/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts +++ b/packages/runtime/src/useCases/anonymous/tokens/LoadPeerTokenAnonymous.ts @@ -7,6 +7,7 @@ import { TokenMapper } from "../../transport/tokens/TokenMapper"; export interface LoadPeerTokenAnonymousRequest { reference: TokenReferenceString; + password?: string; } class Validator extends SchemaValidator { @@ -24,7 +25,7 @@ export class LoadPeerTokenAnonymousUseCase extends UseCase> { - const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference); + const createdToken = await this.anonymousTokenController.loadPeerTokenByTruncated(request.reference, request.password); return Result.ok(TokenMapper.toTokenDTO(createdToken, true)); } } diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index 7fc75add3..08e0438f0 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -51,10 +51,6 @@ class General { public cacheEmpty(entityName: string | Function, id: string) { return new ApplicationError("error.runtime.cacheEmpty", `The cache of ${entityName instanceof Function ? entityName.name : entityName} with id '${id}' is empty.`); } - - public invalidPin(): ApplicationError { - return new ApplicationError("error.runtime.validation.invalidPin", "The PIN is invalid. It must consist of 4 to 16 digits from 0 to 9."); - } } class Serval { @@ -88,6 +84,13 @@ class RelationshipTemplates { ); } + public passwordProtectionMustBeInherited(): ApplicationError { + return new ApplicationError( + "error.runtime.relationshipTemplates.passwordProtectionMustBeInherited", + "If a RelationshipTemplate has password protection, Tokens created from it must have the same password protection." + ); + } + public cannotCreateTokenForPeerTemplate(): ApplicationError { return new ApplicationError("error.runtime.relationshipTemplates.cannotCreateTokenForPeerTemplate", "You cannot create a Token for a peer RelationshipTemplate."); } diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index 10416a7a8..7dec71699 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -7,6 +7,9 @@ export const LoadPeerTokenAnonymousRequest: any = { "properties": { "reference": { "$ref": "#/definitions/TokenReferenceString" + }, + "password": { + "type": "string" } }, "required": [ @@ -20553,6 +20556,9 @@ export const LoadItemFromTruncatedReferenceRequest: any = { "$ref": "#/definitions/RelationshipTemplateReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ @@ -21295,6 +21301,23 @@ export const CreateTokenForFileRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -21333,6 +21356,23 @@ export const CreateTokenQRCodeForFileRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -21557,6 +21597,9 @@ export const GetOrLoadFileRequest: any = { "$ref": "#/definitions/FileReferenceString" } ] + }, + "password": { + "type": "string" } }, "required": [ @@ -22486,6 +22529,23 @@ export const CreateTokenForOwnTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22524,6 +22584,23 @@ export const CreateTokenQRCodeForOwnTemplateRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22747,6 +22824,23 @@ export const CreateOwnTokenRequest: any = { }, "forIdentity": { "$ref": "#/definitions/AddressString" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false } }, "required": [ @@ -22923,6 +23017,9 @@ export const LoadPeerTokenRequest: any = { }, "ephemeral": { "type": "boolean" + }, + "password": { + "type": "string" } }, "required": [ diff --git a/packages/runtime/src/useCases/common/index.ts b/packages/runtime/src/useCases/common/index.ts index cc3b1d7d0..7f1c80950 100644 --- a/packages/runtime/src/useCases/common/index.ts +++ b/packages/runtime/src/useCases/common/index.ts @@ -6,6 +6,7 @@ export * from "./RuntimeErrors"; export * from "./SchemaRepository"; export * from "./UseCase"; export * from "./validation/SchemaValidator"; +export * from "./validation/TokenAndTemplateCreationValidator"; export * from "./validation/ValidatableStrings"; export * from "./validation/ValidationFailure"; export * from "./validation/ValidationResult"; diff --git a/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts b/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts new file mode 100644 index 000000000..008132ab1 --- /dev/null +++ b/packages/runtime/src/useCases/common/validation/TokenAndTemplateCreationValidator.ts @@ -0,0 +1,48 @@ +import { CoreDate } from "@nmshd/core-types"; +import { RuntimeErrors } from "../RuntimeErrors"; +import { JsonSchema } from "../SchemaRepository"; +import { SchemaValidator } from "./SchemaValidator"; +import { ISO8601DateTimeString } from "./ValidatableStrings"; +import { ValidationFailure } from "./ValidationFailure"; +import { ValidationResult } from "./ValidationResult"; + +export class TokenAndTemplateCreationValidator< + T extends { + expiresAt?: ISO8601DateTimeString; + passwordProtection?: { + password: string; + passwordIsPin?: true; + }; + } +> extends SchemaValidator { + public constructor(protected override readonly schema: JsonSchema) { + super(schema); + } + + public override validate(input: T): ValidationResult { + const validationResult = super.validate(input); + + if (input.expiresAt && CoreDate.from(input.expiresAt).isExpired()) { + validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPropertyValue(`'expiresAt' must be in the future`), "expiresAt")); + } + + if (input.passwordProtection) { + const passwordProtection = input.passwordProtection; + + if (passwordProtection.passwordIsPin) { + if (!/^[0-9]{4,16}$/.test(passwordProtection.password)) { + validationResult.addFailure( + new ValidationFailure( + RuntimeErrors.general.invalidPropertyValue( + `'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.` + ), + "passwordProtection" + ) + ); + } + } + } + + return validationResult; + } +} diff --git a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts index 87b9ef427..1ec7f67d9 100644 --- a/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts +++ b/packages/runtime/src/useCases/transport/account/LoadItemFromTruncatedReference.ts @@ -28,6 +28,7 @@ import { TokenMapper } from "../tokens/TokenMapper"; export interface LoadItemFromTruncatedReferenceRequest { reference: TokenReferenceString | FileReferenceString | RelationshipTemplateReferenceString; + password?: string; } class Validator extends SchemaValidator { @@ -65,7 +66,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase> { - const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); + private async handleTokenReference(tokenReference: string, password?: string): Promise> { + const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); @@ -93,7 +94,7 @@ export class LoadItemFromTruncatedReferenceUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenForFileRequest")); } @@ -48,7 +55,8 @@ export class CreateTokenForFileUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenQRCodeForFileRequest")); } @@ -47,7 +54,8 @@ export class CreateTokenQRCodeForFileUseCase extends UseCase { @@ -31,20 +32,20 @@ export class GetOrLoadFileUseCase extends UseCase } protected async executeInternal(request: GetOrLoadFileRequest): Promise> { - const result = await this.loadFileFromReference(request.reference); + const result = await this.loadFileFromReference(request.reference, request.password); await this.accountController.syncDatawallet(); return result; } - private async loadFileFromReference(reference: string): Promise> { + private async loadFileFromReference(reference: string, password?: string): Promise> { if (reference.startsWith(Base64ForIdPrefix.File)) { return await this.loadFileFromFileReference(reference); } if (reference.startsWith(Base64ForIdPrefix.Token)) { - return await this.loadFileFromTokenReference(reference); + return await this.loadFileFromTokenReference(reference, password); } throw RuntimeErrors.files.invalidReference(reference); @@ -55,8 +56,8 @@ export class GetOrLoadFileUseCase extends UseCase return Result.ok(FileMapper.toFileDTO(file)); } - private async loadFileFromTokenReference(truncatedReference: string): Promise> { - const token = await this.tokenController.loadPeerTokenByTruncated(truncatedReference, true); + private async loadFileFromTokenReference(truncatedReference: string, password?: string): Promise> { + const token = await this.tokenController.loadPeerTokenByTruncated(truncatedReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); diff --git a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts index 0a5d14954..712856d40 100644 --- a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts +++ b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateOwnRelationshipTemplate.ts @@ -5,10 +5,8 @@ import { ArbitraryRelationshipTemplateContent, RelationshipTemplateContent } fro import { CoreAddress, CoreDate } from "@nmshd/core-types"; import { AccountController, PasswordProtectionCreationParameters, RelationshipTemplateController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; -import { DateTime } from "luxon"; -import { nameof } from "ts-simple-nameof"; import { RelationshipTemplateDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase, ValidationFailure, ValidationResult } from "../../common"; +import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; import { RelationshipTemplateMapper } from "./RelationshipTemplateMapper"; export interface CreateOwnRelationshipTemplateRequest { @@ -28,32 +26,10 @@ export interface CreateOwnRelationshipTemplateRequest { }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateOwnRelationshipTemplateRequest")); } - - public override validate(input: CreateOwnRelationshipTemplateRequest): ValidationResult { - const validationResult = super.validate(input); - if (!validationResult.isValid()) return validationResult; - - if (DateTime.fromISO(input.expiresAt) <= DateTime.utc()) { - validationResult.addFailure( - new ValidationFailure( - RuntimeErrors.general.invalidPropertyValue(`'${nameof((r) => r.expiresAt)}' must be in the future`), - nameof((r) => r.expiresAt) - ) - ); - } - - if (input.passwordProtection?.passwordIsPin) { - if (!/^[0-9]{4,16}$/.test(input.passwordProtection.password)) { - validationResult.addFailure(new ValidationFailure(RuntimeErrors.general.invalidPin())); - } - } - - return validationResult; - } } export class CreateOwnRelationshipTemplateUseCase extends UseCase { diff --git a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts index 1af27744c..07336d2db 100644 --- a/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts +++ b/packages/runtime/src/useCases/transport/relationshipTemplates/CreateTokenForOwnRelationshipTemplate.ts @@ -2,6 +2,7 @@ import { Result } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { AccountController, + PasswordProtectionCreationParameters, RelationshipTemplate, RelationshipTemplateController, SharedPasswordProtection, @@ -10,7 +11,7 @@ import { } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import { TokenDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RelationshipTemplateIdString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase } from "../../common"; +import { AddressString, ISO8601DateTimeString, RelationshipTemplateIdString, RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; import { TokenMapper } from "../tokens/TokenMapper"; export interface CreateTokenForOwnTemplateRequest { @@ -18,9 +19,16 @@ export interface CreateTokenForOwnTemplateRequest { expiresAt?: ISO8601DateTimeString; ephemeral?: boolean; forIdentity?: AddressString; + passwordProtection?: { + /** + * @minLength 1 + */ + password: string; + passwordIsPin?: true; + }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenForOwnTemplateRequest")); } @@ -51,6 +59,10 @@ export class CreateTokenForOwnTemplateUseCase extends UseCase { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateTokenQRCodeForOwnTemplateRequest")); } @@ -44,6 +67,10 @@ export class CreateTokenQRCodeForOwnTemplateUseCase extends UseCase> { - const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true); + const token = await this.tokenController.loadPeerTokenByTruncated(tokenReference, true, password); if (!token.cache) { throw RuntimeErrors.general.cacheEmpty(Token, token.id.toString()); diff --git a/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts b/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts index a20a15d2f..0699c356b 100644 --- a/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts +++ b/packages/runtime/src/useCases/transport/tokens/CreateOwnToken.ts @@ -1,12 +1,21 @@ import { Serializable } from "@js-soft/ts-serval"; import { Result } from "@js-soft/ts-utils"; import { CoreAddress, CoreDate } from "@nmshd/core-types"; -import { AccountController, TokenController } from "@nmshd/transport"; +import { AccountController, PasswordProtectionCreationParameters, TokenController } from "@nmshd/transport"; import { Inject } from "@nmshd/typescript-ioc"; import { DateTime } from "luxon"; import { nameof } from "ts-simple-nameof"; import { TokenDTO } from "../../../types"; -import { AddressString, ISO8601DateTimeString, RuntimeErrors, SchemaRepository, SchemaValidator, UseCase, ValidationFailure, ValidationResult } from "../../common"; +import { + AddressString, + ISO8601DateTimeString, + RuntimeErrors, + SchemaRepository, + TokenAndTemplateCreationValidator, + UseCase, + ValidationFailure, + ValidationResult +} from "../../common"; import { TokenMapper } from "./TokenMapper"; export interface CreateOwnTokenRequest { @@ -14,9 +23,16 @@ export interface CreateOwnTokenRequest { expiresAt: ISO8601DateTimeString; ephemeral: boolean; forIdentity?: AddressString; + passwordProtection?: { + /** + * @minLength 1 + */ + password: string; + passwordIsPin?: true; + }; } -class Validator extends SchemaValidator { +class Validator extends TokenAndTemplateCreationValidator { public constructor(@Inject schemaRepository: SchemaRepository) { super(schemaRepository.getSchema("CreateOwnTokenRequest")); } @@ -59,7 +75,8 @@ export class CreateOwnTokenUseCase extends UseCase { @@ -29,7 +30,7 @@ export class LoadPeerTokenUseCase extends UseCase> { - const result = await this.tokenController.loadPeerTokenByTruncated(request.reference, request.ephemeral); + const result = await this.tokenController.loadPeerTokenByTruncated(request.reference, request.ephemeral, request.password); if (!request.ephemeral) { await this.accountController.syncDatawallet(); diff --git a/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts b/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts index 84b0a2b54..288be2245 100644 --- a/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts +++ b/packages/runtime/src/useCases/transport/tokens/TokenMapper.ts @@ -18,7 +18,13 @@ export class TokenMapper { expiresAt: token.cache.expiresAt.toString(), truncatedReference: reference.truncate(), isEphemeral: ephemeral, - forIdentity: token.cache.forIdentity?.toString() + forIdentity: token.cache.forIdentity?.toString(), + passwordProtection: token.passwordProtection + ? { + password: token.passwordProtection.password, + passwordIsPin: token.passwordProtection.passwordType.startsWith("pin") ? true : undefined + } + : undefined }; } diff --git a/packages/runtime/test/anonymous/tokens.test.ts b/packages/runtime/test/anonymous/tokens.test.ts index 2c44d0bc9..8fcb6405f 100644 --- a/packages/runtime/test/anonymous/tokens.test.ts +++ b/packages/runtime/test/anonymous/tokens.test.ts @@ -40,4 +40,37 @@ describe("Anonymous tokens", () => { }); expect(result).toBeAnError(/.*/, "error.transport.general.notIntendedForYou"); }); + + describe("Password-protected tokens", () => { + let tokenReference: string; + + beforeAll(async () => { + tokenReference = (await uploadOwnToken(runtimeService.transport, undefined, { password: "password" })).truncatedReference; + }); + + test("send and receive a password-protected token", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.passwordProtection?.password).toBe("password"); + expect(result.value.passwordProtection?.passwordIsPin).toBeUndefined(); + }); + + test("error when loading a token with a wrong password", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with a missing password", async () => { + const result = await noLoginRuntime.anonymousServices.tokens.loadPeerToken({ + reference: tokenReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); }); diff --git a/packages/runtime/test/lib/testUtils.ts b/packages/runtime/test/lib/testUtils.ts index 5207554d3..2f749d352 100644 --- a/packages/runtime/test/lib/testUtils.ts +++ b/packages/runtime/test/lib/testUtils.ts @@ -160,12 +160,17 @@ export async function syncUntilHasEvent( return event; } -export async function uploadOwnToken(transportServices: TransportServices, forIdentity?: string): Promise { +export async function uploadOwnToken( + transportServices: TransportServices, + forIdentity?: string, + passwordProtection?: { password: string; passwordIsPin?: true } +): Promise { const response = await transportServices.tokens.createOwnToken({ content: { aKey: "aValue" }, expiresAt: DateTime.utc().plus({ days: 1 }).toString(), ephemeral: false, - forIdentity + forIdentity, + passwordProtection }); expect(response).toBeSuccessful(); @@ -211,13 +216,19 @@ export const emptyRelationshipTemplateContent: ArbitraryRelationshipTemplateCont export const emptyRelationshipCreationContent: ArbitraryRelationshipCreationContentJSON = ArbitraryRelationshipCreationContent.from({ value: {} }).toJSON(); -export async function createTemplate(transportServices: TransportServices, body?: RelationshipTemplateContentJSON, templateExpiresAt?: DateTime): Promise { +export async function createTemplate( + transportServices: TransportServices, + body?: RelationshipTemplateContentJSON, + passwordProtection?: { password: string; passwordIsPin?: true }, + templateExpiresAt?: DateTime +): Promise { const defaultExpirationDateTime = DateTime.utc().plus({ minutes: 10 }).toString(); const response = await transportServices.relationshipTemplates.createOwnRelationshipTemplate({ maxNumberOfAllocations: 1, expiresAt: templateExpiresAt ? templateExpiresAt.toString() : defaultExpirationDateTime, - content: _.cloneDeep(body) ?? emptyRelationshipTemplateContent + content: _.cloneDeep(body) ?? emptyRelationshipTemplateContent, + passwordProtection }); expect(response).toBeSuccessful(); @@ -240,7 +251,7 @@ export async function exchangeTemplate( content?: RelationshipTemplateContentJSON, templateExpiresAt?: DateTime ): Promise { - const template = await createTemplate(transportServicesCreator, content, templateExpiresAt); + const template = await createTemplate(transportServicesCreator, content, undefined, templateExpiresAt); const response = await transportServicesRecipient.relationshipTemplates.loadPeerRelationshipTemplate({ reference: template.truncatedReference }); expect(response).toBeSuccessful(); diff --git a/packages/runtime/test/transport/passwordProtection/files.test.ts b/packages/runtime/test/transport/passwordProtection/files.test.ts new file mode 100644 index 000000000..0a152c735 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/files.test.ts @@ -0,0 +1,120 @@ +import { RuntimeServiceProvider, TestRuntimeServices, uploadFile } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected tokens for files", () => { + let fileId: string; + + beforeAll(async () => { + fileId = (await uploadFile(runtimeServices1.transport)).id; + }); + + test("send and receive a file via password-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("password"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + }); + + test("send and receive a file via PIN-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "1234", passwordIsPin: true } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("1234"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBe(true); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "1234" }); + expect(loadResult).toBeSuccessful(); + }); + + test("error when loading the file with a wrong password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the file with no password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a file via password-protected token", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: createResult.value.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.type).toBe("File"); + }); + + test("error when loading the file with a wrong password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the file with no password", async () => { + const createResult = await runtimeServices1.transport.files.createTokenForFile({ + fileId, + passwordProtection: { password: "password" } + }); + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts b/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts new file mode 100644 index 000000000..9d84b6a00 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/relationshipTemplates.test.ts @@ -0,0 +1,410 @@ +import { RelationshipTemplateReference } from "@nmshd/transport"; +import { DateTime } from "luxon"; +import { createTemplate, emptyRelationshipTemplateContent, RuntimeServiceProvider, TestRuntimeServices } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected templates", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("password"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection!.password).toBe("1234"); + expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); + const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a template with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "" + } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a template with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "invalid-pin", + passwordIsPin: true + } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected template", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a password-protected template with wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ + content: emptyRelationshipTemplateContent, + expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), + passwordProtection: { + password: "password" + } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); + +describe("Password-protected templates via tokens", () => { + test("send and receive a password-protected template via token", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("password"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected template via token", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "1234", passwordIsPin: true })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "1234", + passwordIsPin: true + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection!.password).toBe("1234"); + expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(loadResult).toBeAnError("Token not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { + password: "password" + } + }); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when token password protection doesn't inherit template password protection", async () => { + const templateId = (await createTemplate(runtimeServices1.transport, undefined, { password: "password" })).id; + + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId + }); + + expect(createResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.passwordProtectionMustBeInherited"); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected template via token", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a password-protected template via token with wrong password", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a password-protected template via token with no password", async () => { + const template = await createTemplate(runtimeServices1.transport, undefined, { password: "password" }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: template.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); + +describe("Password-protected tokens for unprotected templates", () => { + let templateId: string; + + beforeAll(async () => { + templateId = (await createTemplate(runtimeServices1.transport)).id; + }); + + test("send and receive a template via password-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("password"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(loadResult).toBeSuccessful(); + }); + + test("send and receive a template via PIN-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "1234", passwordIsPin: true } + }); + expect(createResult).toBeSuccessful(); + expect(createResult.value.passwordProtection?.password).toBe("1234"); + expect(createResult.value.passwordProtection?.passwordIsPin).toBe(true); + + const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ + reference: createResult.value.truncatedReference, + password: "1234" + }); + expect(loadResult).toBeSuccessful(); + }); + + test("error when loading the template with a wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading the template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + expect(createResult).toBeSuccessful(); + + const loadResult = await runtimeServices2.transport.files.getOrLoadFile({ reference: createResult.value.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId: templateId, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a template via password-protected token", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "password" + }); + expect(result).toBeSuccessful(); + expect(result.value.type).toBe("RelationshipTemplate"); + }); + + test("error when loading a template with wrong password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference, + password: "wrong-password" + }); + expect(result).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a template with no password", async () => { + const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ + templateId, + passwordProtection: { password: "password" } + }); + + const result = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ + reference: createResult.value.truncatedReference + }); + expect(result).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/passwordProtection/tokens.test.ts b/packages/runtime/test/transport/passwordProtection/tokens.test.ts new file mode 100644 index 000000000..126d650d4 --- /dev/null +++ b/packages/runtime/test/transport/passwordProtection/tokens.test.ts @@ -0,0 +1,108 @@ +import { CoreDate } from "@nmshd/core-types"; +import { TokenReference } from "@nmshd/transport"; +import { RuntimeServiceProvider, TestRuntimeServices, uploadOwnToken } from "../../lib"; + +const serviceProvider = new RuntimeServiceProvider(); +let runtimeServices1: TestRuntimeServices; +let runtimeServices2: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(2); + runtimeServices1 = runtimeServices[0]; + runtimeServices2 = runtimeServices[1]; +}, 30000); +afterAll(() => serviceProvider.stop()); + +describe("Password-protected tokens", () => { + test("send and receive a password-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + expect(token.passwordProtection?.password).toBe("password"); + expect(token.passwordProtection?.passwordIsPin).toBeUndefined(); + + const reference = TokenReference.from(token.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pw"); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection?.password).toBe("password"); + expect(loadResult.value.passwordProtection?.passwordIsPin).toBeUndefined(); + }); + + test("send and receive a PIN-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "1234", passwordIsPin: true }); + expect(token.passwordProtection?.password).toBe("1234"); + expect(token.passwordProtection?.passwordIsPin).toBe(true); + + const reference = TokenReference.from(token.truncatedReference); + expect(reference.passwordProtection!.passwordType).toBe("pin4"); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "1234" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.passwordProtection?.password).toBe("1234"); + expect(loadResult.value.passwordProtection?.passwordIsPin).toBe(true); + }); + + test("error when loading a token with a wrong password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ reference: token.truncatedReference, ephemeral: true, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with no password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.tokens.loadPeerToken({ + reference: token.truncatedReference, + ephemeral: true + }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + + test("validation error when creating a token with empty string as the password", async () => { + const createResult = await runtimeServices1.transport.tokens.createOwnToken({ + content: { key: "value" }, + expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString(), + ephemeral: true, + passwordProtection: { password: "" } + }); + expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); + }); + + test("validation error when creating a token with an invalid PIN", async () => { + const createResult = await runtimeServices1.transport.tokens.createOwnToken({ + content: { key: "value" }, + expiresAt: CoreDate.utc().add({ minutes: 10 }).toISOString(), + ephemeral: true, + passwordProtection: { password: "invalid-pin", passwordIsPin: true } + }); + expect(createResult).toBeAnError( + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9.", + "error.runtime.validation.invalidPropertyValue" + ); + }); + + describe("LoadItemFromTruncatedReferenceUseCase", () => { + test("send and receive a password-protected token", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference, password: "password" }); + expect(loadResult).toBeSuccessful(); + expect(loadResult.value.type).toBe("Token"); + }); + + test("error when loading a token with a wrong password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference, password: "wrong-password" }); + expect(loadResult).toBeAnError(/.*/, "error.runtime.recordNotFound"); + }); + + test("error when loading a token with no password", async () => { + const token = await uploadOwnToken(runtimeServices1.transport, undefined, { password: "password" }); + + const loadResult = await runtimeServices2.transport.account.loadItemFromTruncatedReference({ reference: token.truncatedReference }); + expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); + }); + }); +}); diff --git a/packages/runtime/test/transport/relationshipTemplates.test.ts b/packages/runtime/test/transport/relationshipTemplates.test.ts index 6d5ee8fb2..d46560234 100644 --- a/packages/runtime/test/transport/relationshipTemplates.test.ts +++ b/packages/runtime/test/transport/relationshipTemplates.test.ts @@ -1,5 +1,4 @@ import { RelationshipTemplateContent, RelationshipTemplateContentJSON } from "@nmshd/content"; -import { RelationshipTemplateReference } from "@nmshd/transport"; import { DateTime } from "luxon"; import { GetRelationshipTemplatesQuery, OwnerRestriction } from "../../src"; import { emptyRelationshipTemplateContent, QueryParamConditions, RuntimeServiceProvider, TestRuntimeServices } from "../lib"; @@ -248,192 +247,6 @@ describe("RelationshipTemplate Tests", () => { expect(createQRCodeWithoutPersonalizationResult).toBeAnError(/.*/, "error.runtime.relationshipTemplates.personalizationMustBeInherited"); }); }); - - describe("Password-protected templates", () => { - test("send and receive a password-protected template", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - expect(createResult.value.passwordProtection!.password).toBe("password"); - expect(createResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); - expect(reference.passwordProtection!.passwordType).toBe("pw"); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "password" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("password"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - }); - - test("send and receive a PIN-protected template", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "1234", - passwordIsPin: true - } - }); - expect(createResult).toBeSuccessful(); - expect(createResult.value.passwordProtection!.password).toBe("1234"); - expect(createResult.value.passwordProtection!.passwordIsPin).toBe(true); - const reference = RelationshipTemplateReference.from(createResult.value.truncatedReference); - expect(reference.passwordProtection!.passwordType).toBe("pin4"); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "1234" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("1234"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); - }); - - test("send and receive a password-protected template via a token", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "password" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("password"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBeUndefined(); - }); - - test("send and receive a PIN-protected template via a token", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "1234", - passwordIsPin: true - } - }) - ).value.id; - - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "1234" - }); - expect(loadResult).toBeSuccessful(); - expect(loadResult.value.passwordProtection!.password).toBe("1234"); - expect(loadResult.value.passwordProtection!.passwordIsPin).toBe(true); - }); - - test("error when loading a password-protected template with a wrong password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "wrong-password" - }); - expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); - }); - - test("error when loading a password-protected template via token with wrong password", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference, - password: "wrong-password" - }); - expect(loadResult).toBeAnError("RelationshipTemplate not found. Make sure the ID exists and the record is not expired.", "error.runtime.recordNotFound"); - }); - - test("error when loading a password-protected template with no password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }); - expect(createResult).toBeSuccessful(); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference - }); - expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); - }); - - test("error when loading a password-protected template via token with no password", async () => { - const templateId = ( - await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "password" - } - }) - ).value.id; - const createResult = await runtimeServices1.transport.relationshipTemplates.createTokenForOwnTemplate({ templateId }); - - const loadResult = await runtimeServices2.transport.relationshipTemplates.loadPeerRelationshipTemplate({ - reference: createResult.value.truncatedReference - }); - expect(loadResult).toBeAnError(/.*/, "error.transport.noPasswordProvided"); - }); - - test("validation error when creating a template with empty string as the password", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "" - } - }); - expect(createResult).toBeAnError("password must NOT have fewer than 1 characters", "error.runtime.validation.invalidPropertyValue"); - }); - - test("validation error when creating a template with an invalid PIN", async () => { - const createResult = await runtimeServices1.transport.relationshipTemplates.createOwnRelationshipTemplate({ - content: emptyRelationshipTemplateContent, - expiresAt: DateTime.utc().plus({ minutes: 1 }).toString(), - passwordProtection: { - password: "invalid-pin", - passwordIsPin: true - } - }); - expect(createResult).toBeAnError(/.*/, "error.runtime.validation.invalidPin"); - }); - }); }); describe("Serialization Errors", () => { diff --git a/packages/transport/src/core/types/PasswordProtection.ts b/packages/transport/src/core/types/PasswordProtection.ts index 0f61087ff..5e2be302e 100644 --- a/packages/transport/src/core/types/PasswordProtection.ts +++ b/packages/transport/src/core/types/PasswordProtection.ts @@ -1,5 +1,6 @@ import { ISerializable, Serializable, serialize, validate } from "@js-soft/ts-serval"; import { CoreBuffer, ICoreBuffer } from "@nmshd/crypto"; +import { PasswordProtectionCreationParameters } from "./PasswordProtectionCreationParameters"; import { SharedPasswordProtection } from "./SharedPasswordProtection"; export interface IPasswordProtection extends ISerializable { @@ -31,4 +32,15 @@ export class PasswordProtection extends Serializable implements IPasswordProtect salt: this.salt }); } + + public matchesInputForNewPasswordProtection(newPasswordProtection: { password: string; passwordIsPin?: true } | undefined): boolean { + const newCreationParameters = PasswordProtectionCreationParameters.create(newPasswordProtection); + if (!newCreationParameters) return false; + + return this.matchesCreationParameters(newCreationParameters); + } + + private matchesCreationParameters(creationParameters: PasswordProtectionCreationParameters): boolean { + return this.passwordType === creationParameters.passwordType && this.password === creationParameters.password; + } } diff --git a/packages/transport/src/modules/tokens/AnonymousTokenController.ts b/packages/transport/src/modules/tokens/AnonymousTokenController.ts index ea0e27f7f..b950bf000 100644 --- a/packages/transport/src/modules/tokens/AnonymousTokenController.ts +++ b/packages/transport/src/modules/tokens/AnonymousTokenController.ts @@ -2,6 +2,7 @@ import { Serializable } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, CoreId } from "@nmshd/core-types"; import { CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; import { CoreCrypto, IConfig, ICorrelator, TransportCoreErrors } from "../../core"; +import { PasswordProtection } from "../../core/types/PasswordProtection"; import { AnonymousTokenClient } from "./backbone/AnonymousTokenClient"; import { CachedToken } from "./local/CachedToken"; import { Token } from "./local/Token"; @@ -13,17 +14,28 @@ export class AnonymousTokenController { this.client = new AnonymousTokenClient(config, correlator); } - public async loadPeerTokenByTruncated(truncated: string): Promise { + public async loadPeerTokenByTruncated(truncated: string, password?: string): Promise { const reference = TokenReference.fromTruncated(truncated); - return await this.loadPeerToken(reference.id, reference.key, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerToken(reference.id, reference.key, reference.forIdentityTruncated, passwordProtection); } - private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string): Promise { + private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, forIdentityTruncated?: string, passwordProtection?: PasswordProtection): Promise { if (forIdentityTruncated) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); } - const response = (await this.client.getToken(id.toString())).value; + const hashedPassword = passwordProtection ? (await CoreCrypto.deriveHashOutOfPassword(passwordProtection.password, passwordProtection.salt)).toBase64() : undefined; + const response = (await this.client.getToken(id.toString(), hashedPassword)).value; const cipher = CryptoCipher.fromBase64(response.content); const plaintextTokenBuffer = await CoreCrypto.decrypt(cipher, secretKey); @@ -35,7 +47,8 @@ export class AnonymousTokenController { const token = Token.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); const cachedToken = CachedToken.from({ diff --git a/packages/transport/src/modules/tokens/TokenController.ts b/packages/transport/src/modules/tokens/TokenController.ts index a54dd31b5..01afa9b08 100644 --- a/packages/transport/src/modules/tokens/TokenController.ts +++ b/packages/transport/src/modules/tokens/TokenController.ts @@ -5,6 +5,7 @@ import { CoreBuffer, CryptoCipher, CryptoSecretKey } from "@nmshd/crypto"; import { CoreCrypto, TransportCoreErrors, TransportError } from "../../core"; import { DbCollectionName } from "../../core/DbCollectionName"; import { ControllerName, TransportController } from "../../core/TransportController"; +import { PasswordProtection } from "../../core/types/PasswordProtection"; import { AccountController } from "../accounts/AccountController"; import { SynchronizedCollection } from "../sync/SynchronizedCollection"; import { BackboneGetTokensResponse } from "./backbone/BackboneGetTokens"; @@ -44,11 +45,16 @@ export class TokenController extends TransportController { const cipher = await CoreCrypto.encrypt(serializedTokenBuffer, secretKey); + const password = parameters.passwordProtection?.password; + const salt = password ? await CoreCrypto.random(16) : undefined; + const hashedPassword = password ? (await CoreCrypto.deriveHashOutOfPassword(password, salt!)).toBase64() : undefined; + const response = ( await this.client.createToken({ content: cipher.toBase64(), expiresAt: input.expiresAt.toString(), - forIdentity: input.forIdentity?.toString() + forIdentity: input.forIdentity?.toString(), + password: hashedPassword }) ).value; @@ -61,10 +67,19 @@ export class TokenController extends TransportController { forIdentity: input.forIdentity }); + const passwordProtection = parameters.passwordProtection + ? PasswordProtection.from({ + password: parameters.passwordProtection.password, + passwordType: parameters.passwordProtection.passwordType, + salt: salt! + }) + : undefined; + const token = Token.from({ id: CoreId.from(response.id), secretKey: secretKey, isOwn: true, + passwordProtection, cache: cachedToken, cachedAt: CoreDate.utc() }); @@ -100,8 +115,21 @@ export class TokenController extends TransportController { if (ids.length < 1) { return []; } + const tokens = await this.readTokens(ids); + + const resultItems = ( + await this.client.getTokens({ + tokens: await Promise.all( + tokens.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value; - const resultItems = (await this.client.getTokens({ ids })).value; const promises = []; for await (const resultItem of resultItems) { promises.push(this.updateCacheOfExistingTokenInDb(resultItem.id, resultItem)); @@ -113,27 +141,43 @@ export class TokenController extends TransportController { public async fetchCaches(ids: CoreId[]): Promise<{ id: CoreId; cache: CachedToken }[]> { if (ids.length === 0) return []; - - const backboneTokens = await (await this.client.getTokens({ ids: ids.map((id) => id.id) })).value.collect(); + const tokens = await this.readTokens(ids.map((id) => id.toString())); + + const backboneTokens = await ( + await this.client.getTokens({ + tokens: await Promise.all( + tokens.map(async (t) => { + const hashedPassword = t.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(t.passwordProtection.password, t.passwordProtection.salt)).toBase64() + : undefined; + return { id: t.id.toString(), password: hashedPassword }; + }) + ) + }) + ).value.collect(); const decryptionPromises = backboneTokens.map(async (t) => { - const tokenDoc = await this.tokens.read(t.id); - if (!tokenDoc) { - this._log.error( - `Token '${t.id}' not found in local database and the cache fetching was therefore skipped. This should not happen and might be a bug in the application logic.` - ); - return; - } - - const token = Token.from(tokenDoc); - - return { id: CoreId.from(t), cache: await this.decryptToken(t, token.secretKey) }; + const token = tokens.find((token) => token.id.toString() === t.id); + if (!token) return; + return { id: CoreId.from(t.id), cache: await this.decryptToken(t, token.secretKey) }; }); const caches = await Promise.all(decryptionPromises); return caches.filter((c) => c !== undefined); } + private async readTokens(ids: string[]): Promise { + const tokenPromises = ids.map(async (id) => { + const tokenDoc = await this.tokens.read(id); + if (!tokenDoc) { + this._log.error(`Token '${id}' not found in local database. This should not happen and might be a bug in the application logic.`); + return; + } + return Token.from(tokenDoc); + }); + return (await Promise.all(tokenPromises)).filter((t) => t !== undefined); + } + @log() private async updateCacheOfExistingTokenInDb(id: string, response?: BackboneGetTokensResponse) { const tokenDoc = await this.tokens.read(id); @@ -150,10 +194,11 @@ export class TokenController extends TransportController { } private async updateCacheOfToken(token: Token, response?: BackboneGetTokensResponse): Promise { - const tokenId = token.id.toString(); - if (!response) { - response = (await this.client.getToken(tokenId)).value; + const hashedPassword = token.passwordProtection + ? (await CoreCrypto.deriveHashOutOfPassword(token.passwordProtection.password, token.passwordProtection.salt)).toBase64() + : undefined; + response = (await this.client.getToken(token.id.toString(), hashedPassword)).value; } const cachedToken = await this.decryptToken(response, token.secretKey); @@ -184,12 +229,28 @@ export class TokenController extends TransportController { return cachedToken; } - public async loadPeerTokenByTruncated(truncated: string, ephemeral: boolean): Promise { + public async loadPeerTokenByTruncated(truncated: string, ephemeral: boolean, password?: string): Promise { const reference = TokenReference.fromTruncated(truncated); - return await this.loadPeerToken(reference.id, reference.key, ephemeral, reference.forIdentityTruncated); + + if (reference.passwordProtection && !password) throw TransportCoreErrors.general.noPasswordProvided(); + const passwordProtection = reference.passwordProtection + ? PasswordProtection.from({ + salt: reference.passwordProtection.salt, + passwordType: reference.passwordProtection.passwordType, + password: password! + }) + : undefined; + + return await this.loadPeerToken(reference.id, reference.key, ephemeral, reference.forIdentityTruncated, passwordProtection); } - private async loadPeerToken(id: CoreId, secretKey: CryptoSecretKey, ephemeral: boolean, forIdentityTruncated?: string): Promise { + private async loadPeerToken( + id: CoreId, + secretKey: CryptoSecretKey, + ephemeral: boolean, + forIdentityTruncated?: string, + passwordProtection?: PasswordProtection + ): Promise { const tokenDoc = await this.tokens.read(id.toString()); if (!tokenDoc && forIdentityTruncated && !this.parent.identity.address.toString().endsWith(forIdentityTruncated)) { throw TransportCoreErrors.general.notIntendedForYou(id.toString()); @@ -213,7 +274,8 @@ export class TokenController extends TransportController { const token = Token.from({ id: id, secretKey: secretKey, - isOwn: false + isOwn: false, + passwordProtection }); await this.updateCacheOfToken(token); diff --git a/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts b/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts index 142032e48..9a5041566 100644 --- a/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts +++ b/packages/transport/src/modules/tokens/backbone/AnonymousTokenClient.ts @@ -3,7 +3,8 @@ import { ClientResult } from "../../../core/backbone/ClientResult"; import { BackboneGetTokensResponse } from "./BackboneGetTokens"; export class AnonymousTokenClient extends RESTClient { - public async getToken(id: string): Promise> { - return await this.get(`/api/v1/Tokens/${id}`); + public async getToken(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/Tokens/${id}`, request); } } diff --git a/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts b/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts index b7484aa25..f026cbbbc 100644 --- a/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts +++ b/packages/transport/src/modules/tokens/backbone/BackboneGetTokens.ts @@ -1,5 +1,5 @@ export interface BackboneGetTokensRequest { - ids: string[]; + tokens: { id: string; password?: string }[]; } export interface BackboneGetTokensResponse { diff --git a/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts b/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts index 7d1255b6c..6ac8c24f2 100644 --- a/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts +++ b/packages/transport/src/modules/tokens/backbone/BackbonePostTokens.ts @@ -2,6 +2,7 @@ export interface BackbonePostTokensRequest { content: string; expiresAt: string; forIdentity?: string; + password?: string; } export interface BackbonePostTokensResponse { diff --git a/packages/transport/src/modules/tokens/backbone/TokenClient.ts b/packages/transport/src/modules/tokens/backbone/TokenClient.ts index 39dd2a8a5..401d3305f 100644 --- a/packages/transport/src/modules/tokens/backbone/TokenClient.ts +++ b/packages/transport/src/modules/tokens/backbone/TokenClient.ts @@ -13,8 +13,9 @@ export class TokenClient extends RESTClientAuthenticate { return await this.getPaged("/api/v1/Tokens", request); } - public async getToken(id: string): Promise> { - return await this.get(`/api/v1/Tokens/${id}`); + public async getToken(id: string, password?: string): Promise> { + const request = password ? { password } : undefined; + return await this.get(`/api/v1/Tokens/${id}`, request); } public async deleteToken(id: string): Promise> { diff --git a/packages/transport/src/modules/tokens/local/SendTokenParameters.ts b/packages/transport/src/modules/tokens/local/SendTokenParameters.ts index 366cecb06..b7c0f034d 100644 --- a/packages/transport/src/modules/tokens/local/SendTokenParameters.ts +++ b/packages/transport/src/modules/tokens/local/SendTokenParameters.ts @@ -1,11 +1,13 @@ import { ISerializable, Serializable, serialize, type, validate } from "@js-soft/ts-serval"; import { CoreAddress, CoreDate, ICoreAddress, ICoreDate } from "@nmshd/core-types"; +import { IPasswordProtectionCreationParameters, PasswordProtectionCreationParameters } from "../../../core/types/PasswordProtectionCreationParameters"; export interface ISendTokenParameters extends ISerializable { content: ISerializable; expiresAt: ICoreDate; ephemeral: boolean; forIdentity?: ICoreAddress; + passwordProtection?: IPasswordProtectionCreationParameters; } @type("SendTokenParameters") @@ -26,6 +28,10 @@ export class SendTokenParameters extends Serializable implements ISendTokenParam @serialize() public forIdentity?: CoreAddress; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtectionCreationParameters; + public static from(value: ISendTokenParameters): SendTokenParameters { return this.fromAny(value); } diff --git a/packages/transport/src/modules/tokens/local/Token.ts b/packages/transport/src/modules/tokens/local/Token.ts index ee5a23b21..68f906083 100644 --- a/packages/transport/src/modules/tokens/local/Token.ts +++ b/packages/transport/src/modules/tokens/local/Token.ts @@ -3,12 +3,14 @@ import { CoreDate, ICoreDate } from "@nmshd/core-types"; import { CryptoSecretKey, ICryptoSecretKey } from "@nmshd/crypto"; import { nameof } from "ts-simple-nameof"; import { CoreSynchronizable, ICoreSynchronizable } from "../../../core"; +import { IPasswordProtection, PasswordProtection } from "../../../core/types/PasswordProtection"; import { TokenReference } from "../transmission/TokenReference"; import { CachedToken, ICachedToken } from "./CachedToken"; export interface IToken extends ICoreSynchronizable { secretKey: ICryptoSecretKey; isOwn: boolean; + passwordProtection?: IPasswordProtection; cache?: ICachedToken; cachedAt?: ICoreDate; metadata?: any; @@ -18,7 +20,7 @@ export interface IToken extends ICoreSynchronizable { @type("Token") export class Token extends CoreSynchronizable implements IToken { public override readonly technicalProperties = ["@type", "@context", nameof((r) => r.secretKey), nameof((r) => r.isOwn)]; - + public override readonly userdataProperties = [nameof((r) => r.passwordProtection)]; public override readonly metadataProperties = [nameof((r) => r.metadata), nameof((r) => r.metadataModifiedAt)]; @validate() @@ -29,6 +31,10 @@ export class Token extends CoreSynchronizable implements IToken { @serialize() public isOwn: boolean; + @validate({ nullable: true }) + @serialize() + public passwordProtection?: PasswordProtection; + @validate({ nullable: true }) @serialize() public cache?: CachedToken; @@ -50,7 +56,12 @@ export class Token extends CoreSynchronizable implements IToken { } public toTokenReference(): TokenReference { - return TokenReference.from({ id: this.id, key: this.secretKey, forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4) }); + return TokenReference.from({ + id: this.id, + key: this.secretKey, + forIdentityTruncated: this.cache!.forIdentity?.toString().slice(-4), + passwordProtection: this.passwordProtection?.toSharedPasswordProtection() + }); } public truncate(): string { diff --git a/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts b/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts index b8b6800cf..857c668d5 100644 --- a/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts +++ b/packages/transport/test/modules/tokens/AnonymousTokenController.test.ts @@ -95,4 +95,43 @@ describe("AnonymousTokenController", function () { await expect(anonymousTokenController.loadPeerTokenByTruncated(truncatedReference)).rejects.toThrow("error.platform.recordNotFound"); }); + + test("should load a password-protected token", async function () { + const tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + const receivedToken = await anonymousTokenController.loadPeerTokenByTruncated(reference, "password"); + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(Serializable); + expect(receivedToken.cache?.content).toBeInstanceOf(JSONWrapper); + expect((sentToken.cache?.content.toJSON() as any).content).toBe("TestToken"); + expect((receivedToken.cache?.content as any).content).toBe((sentToken.cache?.content as any).content); + expect(receivedToken.passwordProtection!.password).toBe("password"); + expect(receivedToken.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + expect(receivedToken.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + + await expect(anonymousTokenController.loadPeerTokenByTruncated(reference, "wrong-password")).rejects.toThrow("error.platform.recordNotFound"); + await expect(anonymousTokenController.loadPeerTokenByTruncated(reference)).rejects.toThrow("error.transport.noPasswordProvided"); + }); }); diff --git a/packages/transport/test/modules/tokens/TokenController.test.ts b/packages/transport/test/modules/tokens/TokenController.test.ts index 2c4821598..12099f356 100644 --- a/packages/transport/test/modules/tokens/TokenController.test.ts +++ b/packages/transport/test/modules/tokens/TokenController.test.ts @@ -302,6 +302,7 @@ describe("TokenController", function () { await recipient.tokens.loadPeerTokenByTruncated(sentToken.toTokenReference().truncate(), true); }).rejects.toThrow("transport.general.notIntendedForYou"); }); + test("should throw if a personalized token is not loaded by the right identity and it's uncaught before reaching the Backbone", async function () { const expiresAt = CoreDate.utc().add({ minutes: 5 }); const content = Serializable.fromAny({ content: "TestToken" }); @@ -321,6 +322,80 @@ describe("TokenController", function () { }).rejects.toThrow("error.platform.recordNotFound"); }); + test("should create and load a password-protected token", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference(); + const receivedToken = await recipient.tokens.loadPeerTokenByTruncated(reference.truncate(), false, "password"); + tempId1 = sentToken.id; + + testTokens(sentToken, receivedToken, tempDate); + expect(sentToken.cache?.expiresAt.toISOString()).toBe(expiresAt.toISOString()); + expect(sentToken.cache?.content).toBeInstanceOf(Serializable); + expect(sentToken.passwordProtection!.password).toBe("password"); + expect(sentToken.passwordProtection!.salt).toBeDefined(); + expect(sentToken.passwordProtection!.salt).toHaveLength(16); + expect(sentToken.passwordProtection!.passwordType).toBe("pw"); + + expect(reference.passwordProtection!.passwordType).toBe("pw"); + expect(reference.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + + expect(receivedToken.cache?.content).toBeInstanceOf(JSONWrapper); + expect((receivedToken.cache?.content as any).content).toBe((sentToken.cache?.content as any).content); + expect(receivedToken.passwordProtection!.password).toBe("password"); + expect(receivedToken.passwordProtection!.salt).toStrictEqual(sentToken.passwordProtection!.salt); + expect(receivedToken.passwordProtection!.passwordType).toBe("pw"); + }); + + test("should throw an error if loaded with a wrong or missing password", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference = sentToken.toTokenReference().truncate(); + + await expect(recipient.tokens.loadPeerTokenByTruncated(reference, true, "wrongPassword")).rejects.toThrow("error.platform.recordNotFound"); + await expect(recipient.tokens.loadPeerTokenByTruncated(reference, true)).rejects.toThrow("error.transport.noPasswordProvided"); + }); + + test("should fetch multiple password-protected tokens", async function () { + tempDate = CoreDate.utc().subtract(TestUtil.tempDateThreshold); + const expiresAt = CoreDate.utc().add({ minutes: 5 }); + const content = Serializable.fromAny({ content: "TestToken" }); + const sentToken1 = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "password", passwordType: "pw" } + }); + const reference1 = sentToken1.toTokenReference().truncate(); + + const sentToken2 = await sender.tokens.sendToken({ + content, + expiresAt, + ephemeral: false, + passwordProtection: { password: "1234", passwordType: "pin4" } + }); + const reference2 = sentToken2.toTokenReference().truncate(); + + const receivedToken1 = await recipient.tokens.loadPeerTokenByTruncated(reference1, false, "password"); + const receivedToken2 = await recipient.tokens.loadPeerTokenByTruncated(reference2, false, "1234"); + const fetchCachesResult = await recipient.tokens.fetchCaches([receivedToken1.id, receivedToken2.id]); + expect(fetchCachesResult).toHaveLength(2); + }); + test("should delete a token", async function () { const expiresAt = CoreDate.utc().add({ minutes: 5 }); const content = Serializable.fromAny({ content: "TestToken" });