Skip to content

Commit

Permalink
Identity Recovery Kits (#363)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
jkoenig134 authored Dec 12, 2024
1 parent 697f11f commit 1a38321
Show file tree
Hide file tree
Showing 23 changed files with 390 additions and 28 deletions.
3 changes: 3 additions & 0 deletions .dev/appsettings.override.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@
"Provider": "Postgres",
"ConnectionString": "User ID=tokens;Password=Passw0rd;Server=postgres;Port=5432;Database=enmeshed;"
}
},
"Application": {
"didDomainName": "localhost"
}
},
"Tags": {
Expand Down
2 changes: 1 addition & 1 deletion .dev/compose.backbone.env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
BACKBONE_VERSION=6.22.0
BACKBONE_VERSION=6.23.0
1 change: 1 addition & 0 deletions .dev/compose.backbone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
33 changes: 33 additions & 0 deletions packages/app-runtime/test/runtime/Onboarding.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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 () {
Expand All @@ -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();
});
});
2 changes: 2 additions & 0 deletions packages/runtime/src/extensibility/TransportServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DevicesFacade,
FilesFacade,
IdentityDeletionProcessesFacade,
IdentityRecoveryKitsFacade,
MessagesFacade,
PublicRelationshipTemplateReferencesFacade,
RelationshipsFacade,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Result<TokenDTO>> {
return await this.createIdentityRecoveryKitUseCase.execute(request);
}

public async checkForExistingIdentityRecoveryKit(): Promise<Result<CheckForExistingIdentityRecoveryKitResponse>> {
return await this.checkForExistingIdentityRecoveryKitUseCase.execute();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/types/transport/DeviceDTO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ export interface DeviceDTO {
username: string;
isCurrentDevice: boolean;
isOffboarded?: boolean;
isBackupDevice: boolean;
}
10 changes: 10 additions & 0 deletions packages/runtime/src/useCases/common/RuntimeErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -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();
}
37 changes: 37 additions & 0 deletions packages/runtime/src/useCases/common/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<void, CheckForExistingIdentityRecoveryKitResponse> {
public constructor(@Inject private readonly devicesController: DevicesController) {
super();
}

protected async executeInternal(): Promise<Result<CheckForExistingIdentityRecoveryKitResponse>> {
const devices = await this.devicesController.list();

return Result.ok({
exists: devices.some((device) => device.isBackupDevice)
});
}
}
Original file line number Diff line number Diff line change
@@ -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<CreateIdentityRecoveryKitRequest> {
public constructor(@Inject schemaRepository: SchemaRepository) {
super(schemaRepository.getSchema("CreateIdentityRecoveryKitRequest"));
}
}

export class CreateIdentityRecoveryKitUseCase extends UseCase<CreateIdentityRecoveryKitRequest, TokenDTO> {
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<Result<TokenDTO>> {
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./CheckForExistingIdentityRecoveryKit";
export * from "./CreateIdentityRecoveryKit";
1 change: 1 addition & 0 deletions packages/runtime/src/useCases/transport/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading

0 comments on commit 1a38321

Please sign in to comment.