From 45927518b54963f53af9b7fdb8c44ac9a3bdd233 Mon Sep 17 00:00:00 2001 From: Benoit Devos Date: Tue, 21 May 2024 17:55:04 +0200 Subject: [PATCH] feat: enable secret recovery request. logion-network/logion-internal#1256 --- packages/client-node/integration/Secrets.ts | 51 +++++++++++++++--- packages/client/package.json | 2 +- packages/client/src/LogionClient.ts | 25 ++++++--- packages/client/src/SecretRecovery.ts | 15 ++++++ packages/client/src/SecretRecoveryClient.ts | 49 ++++++++++++++++++ packages/client/src/index.ts | 4 +- packages/client/test/SecretRecovery.spec.ts | 57 +++++++++++++++++++++ packages/client/test/Voter.spec.ts | 1 - 8 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 packages/client/src/SecretRecovery.ts create mode 100644 packages/client/src/SecretRecoveryClient.ts create mode 100644 packages/client/test/SecretRecovery.spec.ts diff --git a/packages/client-node/integration/Secrets.ts b/packages/client-node/integration/Secrets.ts index 9f56fd70..ef8def11 100644 --- a/packages/client-node/integration/Secrets.ts +++ b/packages/client-node/integration/Secrets.ts @@ -1,7 +1,15 @@ -import { ClosedIdentityLoc } from "@logion/client"; +import { ClosedIdentityLoc, CreateSecretRecoveryRequest } from "@logion/client"; import { State } from "./Utils"; +import { UUID } from "@logion/node-api"; + +const secretToKeep = "Key"; export async function recoverableSecrets(state: State) { + const requesterIdentityLocId = await addSecrets(state); + await createRecoveryRequest(state, requesterIdentityLocId); +} + +async function addSecrets(state: State): Promise { const { requesterAccount } = state; const client = state.client.withCurrentAccount(requesterAccount); @@ -9,22 +17,51 @@ export async function recoverableSecrets(state: State) { let closedIdentityLoc = locsState.closedLocs.Identity[0] as ClosedIdentityLoc; expect(closedIdentityLoc).toBeInstanceOf(ClosedIdentityLoc); - const name = "Key"; const value = "Encrypted key"; closedIdentityLoc = await closedIdentityLoc.addSecret({ - name, + name: secretToKeep, + value, + }); + const secretToRemove = "secret-to-remove"; + closedIdentityLoc = await closedIdentityLoc.addSecret({ + name: secretToRemove, value, }); + expect(closedIdentityLoc).toBeInstanceOf(ClosedIdentityLoc); let data = closedIdentityLoc.data(); + expect(data.secrets.length).toBe(2); + + closedIdentityLoc = await closedIdentityLoc.removeSecret(secretToRemove); + + data = closedIdentityLoc.data(); expect(data.secrets.length).toBe(1); - expect(data.secrets[0].name).toBe(name); + expect(data.secrets[0].name).toBe(secretToKeep); expect(data.secrets[0].value).toBe(value); - closedIdentityLoc = await closedIdentityLoc.removeSecret(name); + return closedIdentityLoc.locId; +} - data = closedIdentityLoc.data(); - expect(data.secrets.length).toBe(0); +async function createRecoveryRequest(state: State, requesterIdentityLocId: UUID) { + const request: CreateSecretRecoveryRequest = { + requesterIdentityLocId, + secretName: secretToKeep, + challenge: "my-personal-challenge", + userIdentity: { + email: "john.doe@invalid.domain", + firstName: "John", + lastName: "Doe", + phoneNumber: "+1234", + }, + userPostalAddress: { + line1: "Line1", + line2: "Line2", + postalCode: "PostalCode", + city: "City", + country: "Country", + } + } + await state.client.secretRecovery.createSecretRecoveryRequest(request); } diff --git a/packages/client/package.json b/packages/client/package.json index ffd44d95..6c8f0983 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@logion/client", - "version": "0.45.0-7", + "version": "0.45.0-8", "description": "logion SDK for client applications", "main": "dist/index.js", "packageManager": "yarn@3.2.0", diff --git a/packages/client/src/LogionClient.ts b/packages/client/src/LogionClient.ts index 29fd2471..d4ad9174 100644 --- a/packages/client/src/LogionClient.ts +++ b/packages/client/src/LogionClient.ts @@ -20,11 +20,12 @@ import { VoterApi } from "./Voter.js"; import { SponsorshipState, SponsorshipApi } from "./Sponsorship.js"; import { requireDefined } from "./assertions.js"; import { InvitedContributorApi } from "./InvitedContributor.js"; +import { SecretRecoveryApi } from "./SecretRecovery.js"; /** * An instance of LogionClient is connected to a Logion network and * interacts with all its components (including the blockchain). - * + * * It features: * - Access to LGNT balance, transactions and transfer * - LOC management @@ -35,7 +36,7 @@ export class LogionClient { /** * Instantiates a connected client. - * + * * @param config Parameters of a connection to the Logion network. * @returns A connected client. */ @@ -71,6 +72,7 @@ export class LogionClient { this._public = new PublicApi({ sharedState }); this._voter = new VoterApi({ sharedState, logionClient: this }); this._invitedContributor = new InvitedContributorApi({ sharedState, logionClient: this }) + this._secretRecovery = new SecretRecoveryApi({ sharedState }); } private sharedState: SharedState; @@ -81,6 +83,8 @@ export class LogionClient { private readonly _invitedContributor: InvitedContributorApi; + private readonly _secretRecovery: SecretRecoveryApi; + /** * The configuration of this client. */ @@ -143,7 +147,7 @@ export class LogionClient { /** * Overrides current tokens. - * + * * @param tokens The new tokens. * @returns A copy of this client, but using the new tokens. */ @@ -165,7 +169,7 @@ export class LogionClient { /** * Postpones the expiration of valid (see {@link isTokenValid}) JWT tokens. - * + * * @param now Current time, used to check if tokens are still valid or not. * @param threshold If at least one token's expiration falls between now and (now + threshold), then tokens are refreshed. Otherwise, they are not. * @returns An authenticated client using refreshed tokens or this if no refresh occured. @@ -206,7 +210,7 @@ export class LogionClient { /** * Sets current account. - * + * * @param currentAccount The account to use as current. * @returns A client instance with provided current account. */ @@ -295,11 +299,11 @@ export class LogionClient { * a JWT token for each address. A valid JWT token is sent back by a Logion node * if the client was able to sign a random challenge, hence proving that provided * signer is indeed able to sign using provided addresses. - * + * * Note that the signer may be able to sign for more addresses than the once provided. * A call to this method will merge the retrieved tokens with the ones already available. * Older tokens are replaced. - * + * * @param accounts The addresses for which an authentication token must be retrieved. * @param signer The signer that will sign the challenge. * @returns An instance of client with retrived JWT tokens. @@ -503,9 +507,14 @@ export class LogionClient { return this._invitedContributor; } + get secretRecovery(): SecretRecoveryApi { + this.ensureConnected(); + return this._secretRecovery; + } + /** * Disconnects the client from the Logion blockchain. - * @returns + * @returns */ async disconnect() { if(this.sharedState.nodeApi.polkadot.isConnected) { diff --git a/packages/client/src/SecretRecovery.ts b/packages/client/src/SecretRecovery.ts new file mode 100644 index 00000000..d151b512 --- /dev/null +++ b/packages/client/src/SecretRecovery.ts @@ -0,0 +1,15 @@ +import { SharedState } from "./SharedClient.js"; +import { CreateSecretRecoveryRequest, SecretRecoveryClient } from "./SecretRecoveryClient.js"; + +export class SecretRecoveryApi { + + constructor(args: { sharedState: SharedState }) { + this.client = new SecretRecoveryClient(args); + } + + private readonly client: SecretRecoveryClient; + + async createSecretRecoveryRequest(params: CreateSecretRecoveryRequest): Promise { + await this.client.createSecretRecoveryRequest(params); + } +} diff --git a/packages/client/src/SecretRecoveryClient.ts b/packages/client/src/SecretRecoveryClient.ts new file mode 100644 index 00000000..d87c938a --- /dev/null +++ b/packages/client/src/SecretRecoveryClient.ts @@ -0,0 +1,49 @@ +import { UserIdentity, PostalAddress } from "./Types"; +import { LocMultiClient, FetchParameters } from "./LocClient.js"; +import { requireDefined } from "./assertions.js"; +import { SharedState } from "./SharedClient.js"; +import { AxiosInstance } from "axios"; +import { UUID } from "@logion/node-api"; +import { newBackendError } from "./Error.js"; + +export interface CreateSecretRecoveryRequest { + requesterIdentityLocId: UUID; + secretName: string; + challenge: string; + userIdentity: UserIdentity; + userPostalAddress: PostalAddress; +} + +export class SecretRecoveryClient { + + constructor(args: { sharedState: SharedState }) { + this.sharedState = args.sharedState; + } + + private sharedState: SharedState; + + async createSecretRecoveryRequest(params: CreateSecretRecoveryRequest): Promise { + const { secretName, challenge, userIdentity, userPostalAddress, requesterIdentityLocId } = params; + const axios = await this.backend({ locId: requesterIdentityLocId }); + try { + await axios.post("/api/secret-recovery", { + requesterIdentityLocId: requesterIdentityLocId.toString(), + secretName, + challenge, + userIdentity, + userPostalAddress, + }); + } catch (e) { + throw newBackendError(e); + } + } + + private async backend(params: FetchParameters): Promise { + const loc = await LocMultiClient.getLoc({ + ...params, + api: this.sharedState.nodeApi, + }); + const legalOfficer = requireDefined(this.sharedState.legalOfficers.find(lo => lo.account.equals(loc.owner))); + return legalOfficer.buildAxiosToNode(); + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9b2c1dbc..00780233 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2,7 +2,7 @@ * Exposes tools enabling interaction with a Logion network. * An instance of {@link LogionClient} must be created. * Most features of the client require authentication. - * + * * @module */ import { ISubmittableResult } from '@polkadot/types/types'; @@ -33,6 +33,8 @@ export * from './Polling.js'; export * from './Public.js'; export * from './Recovery.js'; export { LegalOfficerDecision, LoRecoveryClient, ProtectionRequest, ProtectionRequestStatus, UpdateParameters, UserActionParameters, CreateProtectionRequest } from './RecoveryClient.js'; +export * from './SecretRecovery.js'; +export * from './SecretRecoveryClient.js'; export * from './SharedClient.js'; export * from './Signer.js'; export * from './State.js'; diff --git a/packages/client/test/SecretRecovery.spec.ts b/packages/client/test/SecretRecovery.spec.ts new file mode 100644 index 00000000..719d48ae --- /dev/null +++ b/packages/client/test/SecretRecovery.spec.ts @@ -0,0 +1,57 @@ +import { LogionClient, LegalOfficerClass, PostalAddress, UserIdentity, } from "../src/index.js"; +import { buildTestConfig, LOGION_CLIENT_CONFIG, ALICE } from "./Utils.js"; +import { buildLocAndRequest } from "./LocUtils.js"; +import { UUID } from "@logion/node-api"; +import { Mock, It, Times } from "moq.ts"; +import { AxiosInstance, AxiosResponse } from "axios"; + +describe("SecretRecovery", () => { + + it("requests a Secret recovery", async () => { + + const identityLoc = buildLocAndRequest(ALICE.account, "CLOSED", "Identity"); + const locId = new UUID(identityLoc.request.id); + const axios = new Mock(); + + const config = buildTestConfig(testConfigFactory => { + testConfigFactory.setupDefaultNetworkState(); + const axiosFactory = testConfigFactory.setupAxiosFactoryMock(); + axiosFactory.setup(instance => instance.buildAxiosInstance(It.IsAny(), It.IsAny())) + .returns(axios.object()); + setupBackend(axios, locId); + const nodeApi = testConfigFactory.setupNodeApiMock(LOGION_CLIENT_CONFIG); + const directoryClient = testConfigFactory.setupDirectoryClientMock(LOGION_CLIENT_CONFIG); + + directoryClient.setup(instance => instance.getLegalOfficers()).returns(Promise.resolve([ + new LegalOfficerClass({ + legalOfficer: ALICE, + axiosFactory: axiosFactory.object(), + }) + ])); + + nodeApi.setup(instance => instance.queries.getLegalOfficerCase(locId)) + .returns(Promise.resolve(identityLoc.loc)); + }) + + const client = await LogionClient.create(config); + await client.secretRecovery.createSecretRecoveryRequest({ + requesterIdentityLocId: locId, + secretName: "secret-name", + challenge: "my-personal-challenge", + userIdentity: {} as UserIdentity, + userPostalAddress: {} as PostalAddress, + }) + axios.verify(instance => instance.post("/api/secret-recovery", It.IsAny()), Times.Once()); + }) +}) + +function setupBackend(axios: Mock, locId: UUID) { + const response = new Mock>(); + axios.setup(instance => instance.post("/api/secret-recovery", It.Is<{ requesterIdentityLocId: string, secretName: string, challenge: string }>(body => + body.requesterIdentityLocId === locId.toString() && + body.secretName === "secret-name" && + body.challenge === "my-personal-challenge", + ))) + .returns(Promise.resolve(response.object())) +} + diff --git a/packages/client/test/Voter.spec.ts b/packages/client/test/Voter.spec.ts index 1de69cc9..ebc6b80a 100644 --- a/packages/client/test/Voter.spec.ts +++ b/packages/client/test/Voter.spec.ts @@ -13,7 +13,6 @@ import { ALICE, buildSimpleNodeApi, buildTestAuthenticatedSharedSate, - buildValidPolkadotAccountId, ItIsUuid, LEGAL_OFFICERS, LOGION_CLIENT_CONFIG,