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] 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" });