From 1a383211927dd4255f655f4247d05edb24ac9028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20K=C3=B6nig?= <33655937+jkoenig134@users.noreply.github.com> Date: Thu, 12 Dec 2024 10:55:20 +0100 Subject: [PATCH] Identity Recovery Kits (#363) * chore: simplify identity client * feat: add the ability to mark a device as a backup device * feat: add delete to TokenController * feat: add recovery kit usecases * feat: add recovery kit facade * ci: always pull azurite * test: add negative tests * fix: sync backup device * fix: add isBackupDevice to DeviceDTO * fix: do not create an ephemeral token * fix: bump backbone * fix: sync datawallet * test: add positive tests * test: add tests for existsIdentityRecoveryKit * chore: test ordering * fix: create backup device with admin permissions * feat: test onboarding with recovery kits * fix: better error message * fix: make not ephemeral everywhere * fix: rename facade * chore: test name * fix: check naming of existance UseCase --- .dev/appsettings.override.json | 3 + .dev/compose.backbone.env | 2 +- .dev/compose.backbone.yml | 1 + .../test/runtime/Onboarding.test.ts | 33 +++++ .../src/extensibility/TransportServices.ts | 2 + .../transport/IdentityRecoveryKitsFacade.ts | 24 ++++ .../extensibility/facades/transport/index.ts | 1 + .../runtime/src/types/transport/DeviceDTO.ts | 1 + .../src/useCases/common/RuntimeErrors.ts | 10 ++ .../runtime/src/useCases/common/Schemas.ts | 37 ++++++ .../transport/devices/DeviceMapper.ts | 3 +- .../CheckForExistingIdentityRecoveryKit.ts | 22 ++++ .../CreateIdentityRecoveryKit.ts | 72 ++++++++++ .../transport/identityRecoveryKits/index.ts | 2 + .../runtime/src/useCases/transport/index.ts | 1 + .../transport/identityRecoveryKits.test.ts | 124 ++++++++++++++++++ .../src/modules/accounts/AccountController.ts | 6 +- .../accounts/backbone/IdentityClient.ts | 2 +- .../src/modules/devices/DevicesController.ts | 41 +++--- .../devices/backbone/BackbonePostDevices.ts | 2 + .../src/modules/devices/local/Device.ts | 14 +- .../devices/local/SendDeviceParameters.ts | 9 +- .../src/modules/tokens/TokenController.ts | 6 + 23 files changed, 390 insertions(+), 28 deletions(-) create mode 100644 packages/runtime/src/extensibility/facades/transport/IdentityRecoveryKitsFacade.ts create mode 100644 packages/runtime/src/useCases/transport/identityRecoveryKits/CheckForExistingIdentityRecoveryKit.ts create mode 100644 packages/runtime/src/useCases/transport/identityRecoveryKits/CreateIdentityRecoveryKit.ts create mode 100644 packages/runtime/src/useCases/transport/identityRecoveryKits/index.ts create mode 100644 packages/runtime/test/transport/identityRecoveryKits.test.ts diff --git a/.dev/appsettings.override.json b/.dev/appsettings.override.json index 738f219f6..45bae3559 100644 --- a/.dev/appsettings.override.json +++ b/.dev/appsettings.override.json @@ -110,6 +110,9 @@ "Provider": "Postgres", "ConnectionString": "User ID=tokens;Password=Passw0rd;Server=postgres;Port=5432;Database=enmeshed;" } + }, + "Application": { + "didDomainName": "localhost" } }, "Tags": { diff --git a/.dev/compose.backbone.env b/.dev/compose.backbone.env index b69f61720..df0112faf 100644 --- a/.dev/compose.backbone.env +++ b/.dev/compose.backbone.env @@ -1 +1 @@ -BACKBONE_VERSION=6.22.0 +BACKBONE_VERSION=6.23.0 diff --git a/.dev/compose.backbone.yml b/.dev/compose.backbone.yml index 4647bc26d..e182e6a51 100644 --- a/.dev/compose.backbone.yml +++ b/.dev/compose.backbone.yml @@ -90,6 +90,7 @@ services: container_name: azure-storage-emulator hostname: azurite image: mcr.microsoft.com/azure-storage/azurite + pull_policy: always command: azurite -d /data/debug.log -l /data --blobHost "0.0.0.0" --queueHost "0.0.0.0" ports: - "10000:10000" diff --git a/packages/app-runtime/test/runtime/Onboarding.test.ts b/packages/app-runtime/test/runtime/Onboarding.test.ts index fd074966f..2e9a0b043 100644 --- a/packages/app-runtime/test/runtime/Onboarding.test.ts +++ b/packages/app-runtime/test/runtime/Onboarding.test.ts @@ -1,8 +1,11 @@ +import { DeviceMapper } from "@nmshd/runtime"; +import { DeviceSharedSecret } from "@nmshd/transport"; import { AppRuntime, AppRuntimeServices } from "../../src"; import { TestUtil } from "../lib"; describe("Onboarding", function () { let runtime: AppRuntime; + let runtime2: AppRuntime; let services1: AppRuntimeServices; @@ -12,10 +15,14 @@ describe("Onboarding", function () { const [localAccount1] = await TestUtil.provideAccounts(runtime, 1); services1 = await runtime.getServices(localAccount1.id); + + runtime2 = await TestUtil.createRuntime(); + await runtime2.start(); }); afterAll(async function () { await runtime.stop(); + await runtime2.stop(); }); test("should throw when onboarding a second account with the same address", async function () { @@ -24,4 +31,30 @@ describe("Onboarding", function () { await expect(() => runtime.accountServices.onboardAccount(onboardingInfoResult.value)).rejects.toThrow("error.app-runtime.onboardedAccountAlreadyExists"); }); + + test("should onboard with a recovery kit and be able to create a new recovery kit", async () => { + const recoveryKitResponse = await services1.transportServices.identityRecoveryKits.createIdentityRecoveryKit({ + profileName: "profileName", + passwordProtection: { password: "aPassword" } + }); + + const token = await runtime2.anonymousServices.tokens.loadPeerToken({ reference: recoveryKitResponse.value.truncatedReference, password: "aPassword" }); + const deviceOnboardingDTO = DeviceMapper.toDeviceOnboardingInfoDTO(DeviceSharedSecret.from(token.value.content.sharedSecret)); + + const result = await runtime2.accountServices.onboardAccount(deviceOnboardingDTO); + expect(result.address!).toBe((await services1.transportServices.account.getIdentityInfo()).value.address); + + const services2 = await runtime2.getServices(result.id); + + await services1.transportServices.account.syncDatawallet(); + const devices = (await services1.transportServices.devices.getDevices()).value; + const backupDevices = devices.filter((device) => device.isBackupDevice); + expect(backupDevices).toHaveLength(0); + + const recoveryKitResponse2 = await services2.transportServices.identityRecoveryKits.createIdentityRecoveryKit({ + profileName: "profileName", + passwordProtection: { password: "aPassword" } + }); + expect(recoveryKitResponse2).toBeSuccessful(); + }); }); diff --git a/packages/runtime/src/extensibility/TransportServices.ts b/packages/runtime/src/extensibility/TransportServices.ts index 5445b3097..23124d542 100644 --- a/packages/runtime/src/extensibility/TransportServices.ts +++ b/packages/runtime/src/extensibility/TransportServices.ts @@ -5,6 +5,7 @@ import { DevicesFacade, FilesFacade, IdentityDeletionProcessesFacade, + IdentityRecoveryKitsFacade, MessagesFacade, PublicRelationshipTemplateReferencesFacade, RelationshipsFacade, @@ -19,6 +20,7 @@ export class TransportServices { @Inject public readonly devices: DevicesFacade, @Inject public readonly files: FilesFacade, @Inject public readonly identityDeletionProcesses: IdentityDeletionProcessesFacade, + @Inject public readonly identityRecoveryKits: IdentityRecoveryKitsFacade, @Inject public readonly messages: MessagesFacade, @Inject public readonly publicRelationshipTemplateReferences: PublicRelationshipTemplateReferencesFacade, @Inject public readonly relationships: RelationshipsFacade, diff --git a/packages/runtime/src/extensibility/facades/transport/IdentityRecoveryKitsFacade.ts b/packages/runtime/src/extensibility/facades/transport/IdentityRecoveryKitsFacade.ts new file mode 100644 index 000000000..8f6895664 --- /dev/null +++ b/packages/runtime/src/extensibility/facades/transport/IdentityRecoveryKitsFacade.ts @@ -0,0 +1,24 @@ +import { Result } from "@js-soft/ts-utils"; +import { Inject } from "@nmshd/typescript-ioc"; +import { TokenDTO } from "../../../types"; +import { + CheckForExistingIdentityRecoveryKitResponse, + CheckForExistingIdentityRecoveryKitUseCase, + CreateIdentityRecoveryKitRequest, + CreateIdentityRecoveryKitUseCase +} from "../../../useCases"; + +export class IdentityRecoveryKitsFacade { + public constructor( + @Inject private readonly createIdentityRecoveryKitUseCase: CreateIdentityRecoveryKitUseCase, + @Inject private readonly checkForExistingIdentityRecoveryKitUseCase: CheckForExistingIdentityRecoveryKitUseCase + ) {} + + public async createIdentityRecoveryKit(request: CreateIdentityRecoveryKitRequest): Promise> { + return await this.createIdentityRecoveryKitUseCase.execute(request); + } + + public async checkForExistingIdentityRecoveryKit(): Promise> { + return await this.checkForExistingIdentityRecoveryKitUseCase.execute(); + } +} diff --git a/packages/runtime/src/extensibility/facades/transport/index.ts b/packages/runtime/src/extensibility/facades/transport/index.ts index 2f313be75..40ec1abeb 100644 --- a/packages/runtime/src/extensibility/facades/transport/index.ts +++ b/packages/runtime/src/extensibility/facades/transport/index.ts @@ -3,6 +3,7 @@ export * from "./ChallengesFacade"; export * from "./DevicesFacade"; export * from "./FilesFacade"; export * from "./IdentityDeletionProcessesFacade"; +export * from "./IdentityRecoveryKitsFacade"; export * from "./MessagesFacade"; export * from "./PublicRelationshipTemplateReferencesFacade"; export * from "./RelationshipsFacade"; diff --git a/packages/runtime/src/types/transport/DeviceDTO.ts b/packages/runtime/src/types/transport/DeviceDTO.ts index 26d55bdd1..cfb712a49 100644 --- a/packages/runtime/src/types/transport/DeviceDTO.ts +++ b/packages/runtime/src/types/transport/DeviceDTO.ts @@ -13,4 +13,5 @@ export interface DeviceDTO { username: string; isCurrentDevice: boolean; isOffboarded?: boolean; + isBackupDevice: boolean; } diff --git a/packages/runtime/src/useCases/common/RuntimeErrors.ts b/packages/runtime/src/useCases/common/RuntimeErrors.ts index 4f0f237e3..8e0232572 100644 --- a/packages/runtime/src/useCases/common/RuntimeErrors.ts +++ b/packages/runtime/src/useCases/common/RuntimeErrors.ts @@ -262,6 +262,15 @@ class IdentityDeletionProcess { } } +class IdentityRecoveryKits { + public datawalletDisabled() { + return new ApplicationError( + "error.runtime.identityRecoveryKits.datawalletDisabled", + "The Datawallet is disabled. IdentityRecoveryKits will only work if the Datawallet is enabled." + ); + } +} + class DeciderModule { public requestConfigDoesNotMatchResponseConfig() { return new ApplicationError("error.runtime.decide.requestConfigDoesNotMatchResponseConfig", "The RequestConfig does not match the ResponseConfig."); @@ -280,5 +289,6 @@ export class RuntimeErrors { public static readonly notifications = new Notifications(); public static readonly attributes = new Attributes(); public static readonly identityDeletionProcess = new IdentityDeletionProcess(); + public static readonly identityRecoveryKits = new IdentityRecoveryKits(); public static readonly deciderModule = new DeciderModule(); } diff --git a/packages/runtime/src/useCases/common/Schemas.ts b/packages/runtime/src/useCases/common/Schemas.ts index b7b04468e..312b3b42d 100644 --- a/packages/runtime/src/useCases/common/Schemas.ts +++ b/packages/runtime/src/useCases/common/Schemas.ts @@ -21753,6 +21753,43 @@ export const UploadOwnFileValidatableRequest: any = { } } +export const CreateIdentityRecoveryKitRequest: any = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/CreateIdentityRecoveryKitRequest", + "definitions": { + "CreateIdentityRecoveryKitRequest": { + "type": "object", + "properties": { + "profileName": { + "type": "string" + }, + "passwordProtection": { + "type": "object", + "properties": { + "password": { + "type": "string", + "minLength": 1 + }, + "passwordIsPin": { + "type": "boolean", + "const": true + } + }, + "required": [ + "password" + ], + "additionalProperties": false + } + }, + "required": [ + "profileName", + "passwordProtection" + ], + "additionalProperties": false + } + } +} + export const GetAttachmentMetadataRequest: any = { "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/GetAttachmentMetadataRequest", diff --git a/packages/runtime/src/useCases/transport/devices/DeviceMapper.ts b/packages/runtime/src/useCases/transport/devices/DeviceMapper.ts index 37e5b9b35..d7971117c 100644 --- a/packages/runtime/src/useCases/transport/devices/DeviceMapper.ts +++ b/packages/runtime/src/useCases/transport/devices/DeviceMapper.ts @@ -19,7 +19,8 @@ export class DeviceMapper { operatingSystem: device.operatingSystem, publicKey: device.publicKey?.toBase64(false), isCurrentDevice: isCurrentDevice, - isOffboarded: device.isOffboarded + isOffboarded: device.isOffboarded, + isBackupDevice: device.isBackupDevice }; } diff --git a/packages/runtime/src/useCases/transport/identityRecoveryKits/CheckForExistingIdentityRecoveryKit.ts b/packages/runtime/src/useCases/transport/identityRecoveryKits/CheckForExistingIdentityRecoveryKit.ts new file mode 100644 index 000000000..9f3130493 --- /dev/null +++ b/packages/runtime/src/useCases/transport/identityRecoveryKits/CheckForExistingIdentityRecoveryKit.ts @@ -0,0 +1,22 @@ +import { Result } from "@js-soft/ts-utils"; +import { DevicesController } from "@nmshd/transport"; +import { Inject } from "@nmshd/typescript-ioc"; +import { UseCase } from "../../common"; + +export interface CheckForExistingIdentityRecoveryKitResponse { + exists: boolean; +} + +export class CheckForExistingIdentityRecoveryKitUseCase extends UseCase { + public constructor(@Inject private readonly devicesController: DevicesController) { + super(); + } + + protected async executeInternal(): Promise> { + const devices = await this.devicesController.list(); + + return Result.ok({ + exists: devices.some((device) => device.isBackupDevice) + }); + } +} diff --git a/packages/runtime/src/useCases/transport/identityRecoveryKits/CreateIdentityRecoveryKit.ts b/packages/runtime/src/useCases/transport/identityRecoveryKits/CreateIdentityRecoveryKit.ts new file mode 100644 index 000000000..cd5aa7e49 --- /dev/null +++ b/packages/runtime/src/useCases/transport/identityRecoveryKits/CreateIdentityRecoveryKit.ts @@ -0,0 +1,72 @@ +import { Result } from "@js-soft/ts-utils"; +import { CoreDate } from "@nmshd/core-types"; +import { AccountController, Device, DevicesController, PasswordProtectionCreationParameters, TokenContentDeviceSharedSecret, TokenController } from "@nmshd/transport"; +import { Inject } from "@nmshd/typescript-ioc"; +import { TokenDTO } from "../../../types"; +import { RuntimeErrors, SchemaRepository, TokenAndTemplateCreationValidator, UseCase } from "../../common"; +import { TokenMapper } from "../tokens/TokenMapper"; + +export interface CreateIdentityRecoveryKitRequest { + profileName: string; + passwordProtection: { + /** + * @minLength 1 + */ + password: string; + passwordIsPin?: true; + }; +} + +class Validator extends TokenAndTemplateCreationValidator { + public constructor(@Inject schemaRepository: SchemaRepository) { + super(schemaRepository.getSchema("CreateIdentityRecoveryKitRequest")); + } +} + +export class CreateIdentityRecoveryKitUseCase extends UseCase { + public constructor( + @Inject private readonly devicesController: DevicesController, + @Inject private readonly tokenController: TokenController, + @Inject private readonly accountController: AccountController, + @Inject validator: Validator + ) { + super(validator); + } + + protected async executeInternal(request: CreateIdentityRecoveryKitRequest): Promise> { + if (!this.accountController.config.datawalletEnabled) return Result.fail(RuntimeErrors.identityRecoveryKits.datawalletDisabled()); + + const devices = await this.devicesController.list(); + + const backupDevices = devices.filter((device) => device.isBackupDevice); + if (backupDevices.length > 0) await this.removeBackupDevices(backupDevices); + + const newBackupDevice = await this.devicesController.sendDevice({ isAdmin: true, isBackupDevice: true, name: "Backup Device" }); + const sharedSecret = await this.devicesController.getSharedSecret(newBackupDevice.id, request.profileName); + const token = await this.tokenController.sendToken({ + content: TokenContentDeviceSharedSecret.from({ sharedSecret }), + expiresAt: CoreDate.from("9999-12-31"), + ephemeral: false, + passwordProtection: PasswordProtectionCreationParameters.create(request.passwordProtection) + }); + + await this.accountController.syncDatawallet(); + + return Result.ok(TokenMapper.toTokenDTO(token, false)); + } + + private async removeBackupDevices(backupDevices: Device[]) { + for (const backupDevice of backupDevices) { + const matchingTokens = await this.tokenController.getTokens({ + "cache.content.@type": "TokenContentDeviceSharedSecret", + "cache.content.sharedSecret.id": backupDevice.id.toString() + }); + + for (const matchingToken of matchingTokens) { + await this.tokenController.delete(matchingToken); + } + + await this.devicesController.delete(backupDevice); + } + } +} diff --git a/packages/runtime/src/useCases/transport/identityRecoveryKits/index.ts b/packages/runtime/src/useCases/transport/identityRecoveryKits/index.ts new file mode 100644 index 000000000..84c918983 --- /dev/null +++ b/packages/runtime/src/useCases/transport/identityRecoveryKits/index.ts @@ -0,0 +1,2 @@ +export * from "./CheckForExistingIdentityRecoveryKit"; +export * from "./CreateIdentityRecoveryKit"; diff --git a/packages/runtime/src/useCases/transport/index.ts b/packages/runtime/src/useCases/transport/index.ts index 51425e014..279bcbc1b 100644 --- a/packages/runtime/src/useCases/transport/index.ts +++ b/packages/runtime/src/useCases/transport/index.ts @@ -3,6 +3,7 @@ export * from "./challenges"; export * from "./devices"; export * from "./files"; export * from "./identityDeletionProcesses"; +export * from "./identityRecoveryKits"; export * from "./messages"; export * from "./publicRelationshipTemplateReferences"; export * from "./relationships"; diff --git a/packages/runtime/test/transport/identityRecoveryKits.test.ts b/packages/runtime/test/transport/identityRecoveryKits.test.ts new file mode 100644 index 000000000..3f1eef297 --- /dev/null +++ b/packages/runtime/test/transport/identityRecoveryKits.test.ts @@ -0,0 +1,124 @@ +import { OwnerRestriction } from "../../src"; +import { RuntimeServiceProvider, TestRuntimeServices } from "../lib"; + +const serviceProvider = new RuntimeServiceProvider(); + +let services: TestRuntimeServices; +let servicesWithDisabledDatawallet: TestRuntimeServices; + +beforeAll(async () => { + const runtimeServices = await serviceProvider.launch(1, { enableDatawallet: true }); + services = runtimeServices[0]; + + const datawalletDisabledServices = await serviceProvider.launch(1, { enableDatawallet: false }); + servicesWithDisabledDatawallet = datawalletDisabledServices[0]; +}, 30000); + +afterAll(async () => await serviceProvider.stop()); + +afterEach(async () => { + const devicesController = services.transport.identityRecoveryKits["createIdentityRecoveryKitUseCase"]["devicesController"]; + const tokenController = services.transport.identityRecoveryKits["createIdentityRecoveryKitUseCase"]["tokenController"]; + + const devices = await devicesController.list(); + + const backupDevices = devices.filter((device) => device.isBackupDevice); + for (const backupDevice of backupDevices) { + const matchingTokens = await tokenController.getTokens({ + "cache.content.@type": "TokenContentDeviceSharedSecret", + "cache.content.sharedSecret.id": backupDevice.id.toString() + }); + + for (const matchingToken of matchingTokens) { + await tokenController.delete(matchingToken); + } + + await devicesController.delete(backupDevice); + } +}); + +describe("Identity Recovery Kits", () => { + test("should create a recovery kit", async () => { + const response = await services.transport.identityRecoveryKits.createIdentityRecoveryKit({ + profileName: "profileName", + passwordProtection: { password: "aPassword" } + }); + expect(response).toBeSuccessful(); + + const devices = (await services.transport.devices.getDevices()).value; + const backupDevices = devices.filter((device) => device.isBackupDevice); + expect(backupDevices).toHaveLength(1); + + const backupDevice = backupDevices[0]; + + const tokens = (await services.transport.tokens.getTokens({ ownerRestriction: OwnerRestriction.Own, query: { expiresAt: "^9999-12-31" } })).value; + expect(tokens).toHaveLength(1); + + expect(tokens[0].content["@type"]).toBe("TokenContentDeviceSharedSecret"); + expect(tokens[0].content.sharedSecret.id).toBe(backupDevice.id.toString()); + }); + + test("should delete a recovery kit and its token when creating a consecutive recovery kit", async () => { + const firstToken = (await services.transport.identityRecoveryKits.createIdentityRecoveryKit({ profileName: "profileName", passwordProtection: { password: "aPassword" } })) + .value; + const firstBackupDevice = (await services.transport.devices.getDevices()).value.find((device) => device.isBackupDevice)!; + + const response = await services.transport.identityRecoveryKits.createIdentityRecoveryKit({ profileName: "profileName", passwordProtection: { password: "aPassword" } }); + expect(response).toBeSuccessful(); + + const getTokenResponse = await services.transport.tokens.getToken({ id: firstToken.id }); + expect(getTokenResponse).toBeAnError("Token not found", "error.runtime.recordNotFound"); + + const getDeviceResponse = await services.transport.devices.getDevice({ id: firstBackupDevice.id }); + expect(getDeviceResponse).toBeAnError("Device not found", "error.runtime.recordNotFound"); + }); + + test("should tell that no recovery kit exists", async () => { + const response = await services.transport.identityRecoveryKits.checkForExistingIdentityRecoveryKit(); + expect(response).toBeSuccessful(); + + expect(response.value.exists).toBe(false); + }); + + test("should tell that a recovery kit exists", async () => { + await services.transport.identityRecoveryKits.createIdentityRecoveryKit({ profileName: "profileName", passwordProtection: { password: "aPassword" } }); + + const response = await services.transport.identityRecoveryKits.checkForExistingIdentityRecoveryKit(); + expect(response).toBeSuccessful(); + + expect(response.value.exists).toBe(true); + }); + + describe("errors", () => { + test("should not be possible to create a recovery kit when the datawallet is disabled", async () => { + const response = await servicesWithDisabledDatawallet.transport.identityRecoveryKits.createIdentityRecoveryKit({ + profileName: "profileName", + passwordProtection: { password: "password" } + }); + expect(response).toBeAnError( + "The Datawallet is disabled. IdentityRecoveryKits will only work if the Datawallet is enabled.", + "error.runtime.identityRecoveryKits.datawalletDisabled" + ); + }); + + test.each([ + [{ password: "" }, "passwordProtection/password must NOT have fewer than 1 characters"], + [ + { password: "aPassword", passwordIsPin: true }, + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9." + ], + [ + { password: "123", passwordIsPin: true }, + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9." + ], + [ + { password: "123456789123456789123456789123456789", passwordIsPin: true }, + "'passwordProtection.passwordIsPin' is true, hence 'passwordProtection.password' must consist of 4 to 16 digits from 0 to 9." + ], + [{ password: 123, passwordIsPin: true }, "passwordProtection/password must be string"] + ])("should reject the invalid password protection '%p'", async (passwordProtection: any, errorMessage) => { + const response = await services.transport.identityRecoveryKits.createIdentityRecoveryKit({ profileName: "profileName", passwordProtection }); + expect(response).toBeAnError(errorMessage, "error.runtime.validation.invalidPropertyValue"); + }); + }); +}); diff --git a/packages/transport/src/modules/accounts/AccountController.ts b/packages/transport/src/modules/accounts/AccountController.ts index 09cfc4a4e..b990d6c9b 100644 --- a/packages/transport/src/modules/accounts/AccountController.ts +++ b/packages/transport/src/modules/accounts/AccountController.ts @@ -329,7 +329,8 @@ export class AccountController { type: deviceInfo.type, certificate: "", username: createdIdentity.device.username, - datawalletVersion: this._config.supportedDatawalletVersion + datawalletVersion: this._config.supportedDatawalletVersion, + isBackupDevice: false }); // Initialize required controllers @@ -381,7 +382,8 @@ export class AccountController { publicKey: deviceKeypair.publicKey, username: deviceSharedSecret.username, initialPassword: undefined, - isAdmin: deviceSharedSecret.identityPrivateKey ? true : false + isAdmin: deviceSharedSecret.identityPrivateKey ? true : false, + isBackupDevice: false }); // Initialize required controllers diff --git a/packages/transport/src/modules/accounts/backbone/IdentityClient.ts b/packages/transport/src/modules/accounts/backbone/IdentityClient.ts index 4fd153c87..85d241c50 100644 --- a/packages/transport/src/modules/accounts/backbone/IdentityClient.ts +++ b/packages/transport/src/modules/accounts/backbone/IdentityClient.ts @@ -6,6 +6,6 @@ export class IdentityClient extends RESTClient { protected override _logDirective = RESTClientLogDirective.LogResponse; public async createIdentity(value: BackbonePostIdentityRequest): Promise> { - return await this.post("/api/v1/Identities", value, {}); + return await this.post("/api/v1/Identities", value); } } diff --git a/packages/transport/src/modules/devices/DevicesController.ts b/packages/transport/src/modules/devices/DevicesController.ts index 6fdbeb8a7..c6bdcb95a 100644 --- a/packages/transport/src/modules/devices/DevicesController.ts +++ b/packages/transport/src/modules/devices/DevicesController.ts @@ -40,7 +40,21 @@ export class DevicesController extends TransportController { await this.devices.create(device); } - private async createDevice(name = "", description?: string, isAdmin = false): Promise { + public async sendDevice(parameters: ISendDeviceParameters): Promise { + if (!parameters.name) { + const devices = await this.parent.devices.list(); + parameters.name = `Device ${devices.length + 1}`; + } + + const parsedParams = SendDeviceParameters.from(parameters); + const device = await this.createDevice(parsedParams); + + await this.devices.create(device); + + return device; + } + + private async createDevice(params: SendDeviceParameters): Promise { const [signedChallenge, devicePwdDn] = await Promise.all([this.parent.challenges.createChallenge(ChallengeType.Identity), PasswordGenerator.createStrongPassword(45, 50)]); this.log.trace("Device Creation Challenge signed. Creating device on backbone..."); @@ -48,7 +62,8 @@ export class DevicesController extends TransportController { const response = ( await this.client.createDevice({ signedChallenge: signedChallenge.toJSON(), - devicePassword: devicePwdDn + devicePassword: devicePwdDn, + isBackupDevice: params.isBackupDevice }) ).value; @@ -58,32 +73,18 @@ export class DevicesController extends TransportController { createdAt: CoreDate.from(response.createdAt), createdByDevice: CoreId.from(response.createdByDevice), id: CoreId.from(response.id), - name: name, - description: description, + name: params.name, + description: params.description, type: DeviceType.Unknown, username: response.username, initialPassword: devicePwdDn, - isAdmin: isAdmin + isAdmin: params.isAdmin, + isBackupDevice: response.isBackupDevice }); return device; } - public async sendDevice(parameters: ISendDeviceParameters): Promise { - parameters = SendDeviceParameters.from(parameters); - - if (!parameters.name) { - const devices = await this.parent.devices.list(); - parameters.name = `Device ${devices.length + 1}`; - } - - const device = await this.createDevice(parameters.name, parameters.description, parameters.isAdmin); - - await this.devices.create(device); - - return device; - } - public async getSharedSecret(id: CoreId, profileName?: string): Promise { const deviceDoc = await this.devices.read(id.toString()); if (!deviceDoc) { diff --git a/packages/transport/src/modules/devices/backbone/BackbonePostDevices.ts b/packages/transport/src/modules/devices/backbone/BackbonePostDevices.ts index 206449d32..34ede8b73 100644 --- a/packages/transport/src/modules/devices/backbone/BackbonePostDevices.ts +++ b/packages/transport/src/modules/devices/backbone/BackbonePostDevices.ts @@ -3,6 +3,7 @@ import { IChallengeSignedSerialized } from "../../challenges/data/ChallengeSigne export interface BackbonePostDevicesRequest { devicePassword: string; signedChallenge: IChallengeSignedSerialized; + isBackupDevice?: boolean; } export interface BackbonePostDevicesResponse { @@ -10,4 +11,5 @@ export interface BackbonePostDevicesResponse { username: string; createdAt: string; createdByDevice: string; + isBackupDevice: boolean; } diff --git a/packages/transport/src/modules/devices/local/Device.ts b/packages/transport/src/modules/devices/local/Device.ts index 9f9788f48..dfdf8a187 100644 --- a/packages/transport/src/modules/devices/local/Device.ts +++ b/packages/transport/src/modules/devices/local/Device.ts @@ -32,6 +32,7 @@ export interface IDevice extends ICoreSynchronizable { initialPassword?: string; datawalletVersion?: number; isOffboarded?: boolean; + isBackupDevice: boolean; } @type("Device") @@ -50,7 +51,8 @@ export class Device extends CoreSynchronizable implements IDevice { nameof((d) => d.username), nameof((d) => d.initialPassword), nameof((d) => d.datawalletVersion), - nameof((d) => d.isOffboarded) + nameof((d) => d.isOffboarded), + nameof((d) => d.isBackupDevice) ]; public override readonly userdataProperties = [nameof((d) => d.name), nameof((d) => d.description)]; @@ -113,6 +115,16 @@ export class Device extends CoreSynchronizable implements IDevice { @serialize() public isOffboarded?: boolean; + @validate() + @serialize() + public isBackupDevice: boolean; + + protected static override preFrom(value: any): any { + if (value.isBackupDevice === undefined) value.isBackupDevice = false; + + return value; + } + public static from(value: IDevice): Device { return this.fromAny(value); } diff --git a/packages/transport/src/modules/devices/local/SendDeviceParameters.ts b/packages/transport/src/modules/devices/local/SendDeviceParameters.ts index 60ef20c84..3d2ecfd6b 100644 --- a/packages/transport/src/modules/devices/local/SendDeviceParameters.ts +++ b/packages/transport/src/modules/devices/local/SendDeviceParameters.ts @@ -4,13 +4,14 @@ export interface ISendDeviceParameters extends ISerializable { name?: string; description?: string; isAdmin?: boolean; + isBackupDevice?: boolean; } @type("SendDeviceParameters") export class SendDeviceParameters extends Serializable implements ISendDeviceParameters { - @validate({ nullable: true }) + @validate() @serialize() - public name?: string; + public name: string; @validate({ nullable: true }) @serialize() @@ -20,6 +21,10 @@ export class SendDeviceParameters extends Serializable implements ISendDevicePar @serialize() public isAdmin?: boolean; + @validate({ nullable: true }) + @serialize() + public isBackupDevice?: boolean; + public static from(value: ISendDeviceParameters): SendDeviceParameters { return this.fromAny(value); } diff --git a/packages/transport/src/modules/tokens/TokenController.ts b/packages/transport/src/modules/tokens/TokenController.ts index 01afa9b08..051885d63 100644 --- a/packages/transport/src/modules/tokens/TokenController.ts +++ b/packages/transport/src/modules/tokens/TokenController.ts @@ -294,4 +294,10 @@ export class TokenController extends TransportController { await this.tokens.delete(token); } } + + public async delete(token: Token): Promise { + if (token.isOwn) await this.client.deleteToken(token.id.toString()); + + await this.tokens.delete(token); + } }