diff --git a/libs/hw-trustchain/src/StreamTree.ts b/libs/hw-trustchain/src/StreamTree.ts index 55af43a8485c..2ad197c45e9a 100644 --- a/libs/hw-trustchain/src/StreamTree.ts +++ b/libs/hw-trustchain/src/StreamTree.ts @@ -1,5 +1,5 @@ -import { CommandStream, Device } from "."; -import { DerivationPath } from "./Crypto"; +import { CommandStream, CommandStreamDecoder, CommandStreamEncoder, Device } from "."; +import { crypto, DerivationPath } from "./Crypto"; import { IndexedTree } from "./IndexedTree"; /** @@ -149,6 +149,36 @@ export class StreamTree { return new StreamTree(newTree); } + public serialize(): Record { + const streamEntries = serializeTree(this.tree, []); + const entries = streamEntries.flatMap(([path, stream]) => + stream ? [[path, crypto.to_hex(CommandStreamEncoder.encode(stream.blocks))]] : [], + ); + return Object.fromEntries(entries); + + function serializeTree( + tree: IndexedTree, + path: number[], + ): [string, CommandStream | null][] { + const stream = tree.getValue(); + const childrens = tree.getChildren(); + + return [ + [DerivationPath.toString(path), stream], + ...Array.from(childrens.entries()).flatMap(([index, child]) => + serializeTree(child, [...path, index]), + ), + ]; + } + } + + static deserialize(data: Record): StreamTree { + const streams = Object.values(data).map( + data => new CommandStream(CommandStreamDecoder.decode(crypto.from_hex(data))), + ); + return StreamTree.from(...streams); + } + static async createNewTree(owner: Device, opts: StreamTreeCreateOpts = {}): Promise { let stream = new CommandStream(); const streamToIssue = stream.edit().seed(opts.topic); diff --git a/libs/trustchain/src/__tests__/unit/sdk.test.ts b/libs/trustchain/src/__tests__/unit/sdk.test.ts new file mode 100644 index 000000000000..6b414168cb7e --- /dev/null +++ b/libs/trustchain/src/__tests__/unit/sdk.test.ts @@ -0,0 +1,236 @@ +import { DefaultBodyType, http, HttpResponse, PathParams, StrictRequest } from "msw"; +import { setupServer } from "msw/node"; +import { + CommandBlock, + crypto, + Device, + Permissions, + SoftwareDevice, + StreamTree, +} from "@ledgerhq/hw-trustchain"; +import { getEnv } from "@ledgerhq/live-env"; +import { PutCommandsRequest } from "../../api"; +import { HWDeviceProvider } from "../../HWDeviceProvider"; +import { convertLiveCredentialsToKeyPair, SDK } from "../../sdk"; +import { TrustchainResultType } from "../../types"; + +describe("Trustchain SDK", () => { + // Setup API calls mocks + const apiMocks = { + getTrustchainsMock: jest.fn(), + getTrustchainByIdMock: jest.fn(), + getChalenge: jest.fn(), + postAuthenticate: jest.fn(), + postSeed: jest.fn]>(), + postDerivation: jest.fn]>(), + putCommands: jest.fn]>(), + }; + const mswServer = setupServer( + http.get("*/v1/trustchains", () => HttpResponse.json(apiMocks.getTrustchainsMock())), + http.get("*/v1/trustchain/ROOTID", () => HttpResponse.json(apiMocks.getTrustchainByIdMock())), + http.get("*/v1/challenge", () => HttpResponse.json(apiMocks.getChalenge())), + http.post("*/v1/authenticate", () => HttpResponse.json(apiMocks.postAuthenticate())), + http.post("*/v1/seed", ({ request }) => + HttpResponse.json(apiMocks.postSeed(request)), + ), + http.post("*/v1/trustchain/ROOTID/derivation", ({ request }) => + HttpResponse.json(apiMocks.postDerivation(request)), + ), + http.put("*/v1/trustchain/ROOTID/commands", ({ request }) => + HttpResponse.json(apiMocks.putCommands(request)), + ), + http.all("*", () => HttpResponse.json({})), + ); + mswServer.listen(); + + // Setup APDU device interactions mocks + const HWDeviceProviderMethodsMocks = { + withJwt: jest.fn(), + withHw: jest.fn(), + refreshJwt: jest.fn(), + clearJwt: jest.fn(), + } satisfies Partial; + const hwDeviceProviderMock = HWDeviceProviderMethodsMocks as unknown as HWDeviceProvider; + + afterAll(() => { + mswServer.close(); + }); + + const apiBaseUrl = getEnv("TRUSTCHAIN_API_STAGING"); + const sdkContext = { applicationId: 16, name: "alice", apiBaseUrl }; + + beforeEach(() => { + Object.values(apiMocks).forEach(mock => mock.mockClear()); + Object.values(HWDeviceProviderMethodsMocks).forEach(mock => mock.mockClear()); + }); + + it("encryptUserData + decryptUserData", async () => { + const sdk = new SDK(sdkContext, hwDeviceProviderMock); + const obj = new Uint8Array([1, 2, 3, 4, 5]); + const keypair = await crypto.randomKeypair(); + const trustchain = { + rootId: "", + walletSyncEncryptionKey: crypto.to_hex(keypair.privateKey), + applicationPath: "m/0'/16'/0'", + }; + const encrypted = await sdk.encryptUserData(trustchain, obj); + const decrypted = await sdk.decryptUserData(trustchain, encrypted); + expect(decrypted).toEqual(obj); + }); + + it("should create Trustchain", async () => { + const { alice } = MOCK_DATA.members; + + // Mock trustchain states: + const device = new SoftwareDevice(convertLiveCredentialsToKeyPair(alice)); + const initialTree = await createTrustChain(device); + const oneMemberTree = await addMember(device, "m/0'/16'/0'", "alice")(initialTree); + + // Mock API calls: + apiMocks.getTrustchainsMock.mockReturnValueOnce({}); + apiMocks.getTrustchainsMock.mockReturnValueOnce({ ROOTID: initialTree.serialize() }); + apiMocks.getTrustchainByIdMock.mockReturnValue(initialTree.serialize()); + + // Mock APDU device interactions: + HWDeviceProviderMethodsMocks.withJwt.mockImplementation(async (deviceId, job) => + job({ accessToken: "ACCESS TOKEN" }), + ); + HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(initialTree); + HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(oneMemberTree); + + // Run the test: + const sdk = new SDK(sdkContext, hwDeviceProviderMock); + const { type, trustchain } = await sdk.getOrCreateTrustchain("", alice); + + // Check expectations: + expect(type).toBe(TrustchainResultType.created); + expect(trustchain).toEqual({ + applicationPath: "m/0'/16'/0'", + rootId: "ROOTID", + walletSyncEncryptionKey: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + + expect(await jsonRequestContent(apiMocks.postSeed)).toEqual([oneMemberTree.serialize()["m/"]]); + expect(await jsonRequestContent(apiMocks.postDerivation)).toEqual([ + oneMemberTree.serialize()["m/0'/16'/0'"], + ]); + expect(apiMocks.putCommands).not.toHaveBeenCalled(); + + expect(HWDeviceProviderMethodsMocks.withJwt).toHaveBeenCalled(); + expect(HWDeviceProviderMethodsMocks.withHw).toHaveBeenCalledTimes(2); + }); + + it("should remove a member from the Trustchain", async () => { + const { alice, bob, charlie } = MOCK_DATA.members; + + // Mock trustchain states: + const device = new SoftwareDevice(convertLiveCredentialsToKeyPair(alice)); + const closedStreamTree = await createTrustChain(device) + .then(addMember(device, "m/0'/16'/0'", "alice")) + .then(addMember(device, "m/0'/16'/0'", "bob")) + .then(addMember(device, "m/0'/16'/0'", "charlie")) + .then(tree => tree.close("m/0'/16'/0'", device)); + const rmMembersTree = await addMember(device, "m/0'/16'/1'", "alice")(closedStreamTree); + + // Mock API calls: + apiMocks.getTrustchainByIdMock.mockReturnValue(closedStreamTree.serialize()); + apiMocks.getChalenge.mockReturnValue({ json: {}, tlv: MOCK_DATA.challengeTlv }); + apiMocks.postAuthenticate.mockReturnValue({ + accessToken: "BACKEND JWT", + permissions: { ROOTID: { "m/0'/16'/1'": ["owner"] } }, + }); + + // Mock APDU device interactions: + HWDeviceProviderMethodsMocks.withJwt.mockImplementation(async (deviceId, job) => + job({ accessToken: "ACCESS TOKEN" }), + ); + HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(closedStreamTree); + HWDeviceProviderMethodsMocks.withHw.mockResolvedValueOnce(rmMembersTree); + + // Mock the lifecycle callback: + const afterRotation = jest.fn(); + const onTrustchainRotation = jest.fn(); + onTrustchainRotation.mockResolvedValueOnce(afterRotation); + + // Run the test: + const sdk = new SDK(sdkContext, hwDeviceProviderMock, { onTrustchainRotation }); + + const trustchain = { + applicationPath: "m/0'/16'/0'", + rootId: "ROOTID", + walletSyncEncryptionKey: "", + }; + const memberToRemove = { + name: "bob", + id: bob.pubkey, + permissions: Permissions.OWNER, + }; + const newTrustchain = await sdk.removeMember("", trustchain, alice, memberToRemove); + + // Check expectations: + expect(newTrustchain).toEqual({ + applicationPath: "m/0'/16'/1'", + rootId: "ROOTID", + walletSyncEncryptionKey: expect.stringMatching(/^[0-9a-f]{64}$/), + }); + + expect(await jsonRequestContent(apiMocks.postDerivation)).toEqual([ + rmMembersTree.serialize()["m/0'/16'/1'"], + ]); + + const putCommands = await jsonRequestContent(apiMocks.putCommands); + const closeBlock = putCommands.find(_ => _.path === "m/0'/16'/0'")?.blocks[0] ?? ""; + const pushMemberBlock = putCommands.find(_ => _.path === "m/0'/16'/1'")?.blocks[0] ?? ""; + expect(putCommands).toEqual([ + { path: "m/0'/16'/1'", blocks: [pushMemberBlock] }, + { path: "m/0'/16'/0'", blocks: [closeBlock] }, // The closed stream command is sent last + ]); + expect(closeBlock).toBe(closedStreamTree.serialize()["m/0'/16'/0'"].slice(-closeBlock.length)); + expect(pushMemberBlock).not.toContain(bob.pubkey); + expect(pushMemberBlock).toContain(charlie.pubkey); + + expect(HWDeviceProviderMethodsMocks.withJwt).toHaveBeenCalled(); + expect(HWDeviceProviderMethodsMocks.withHw).toHaveBeenCalledTimes(2); + expect(HWDeviceProviderMethodsMocks.refreshJwt).toHaveBeenCalledTimes(1); + + expect(onTrustchainRotation).toHaveBeenCalledWith(sdk, trustchain, alice); + expect(afterRotation).toHaveBeenCalledWith(newTrustchain); + }); +}); + +const MOCK_DATA = { + members: { + alice: { + pubkey: "02e3311a12c450604725f02d1a775ef5cdb4a1b832eb41ac6b1302adbe92a612fc", + privatekey: "873f500bd20783224f7e78d4f8cce3d2bf69eb8008fbd697d20bbea31a721a03", + }, + bob: { + pubkey: "034ac6813695b0d5e033a2a19061c83951e2241aad62ec8fa347a944831c07ea82", + }, + charlie: { + pubkey: "03b4165cddf39e58f3a89682fdf1ccba213167084fecdbb3b86669d40d201df1cf", + }, + }, + + challengeTlv: + "010107020100121053801a35c2e24b627d6e4925ce318980140101154630440220319b42a416512437e48d9c9bf204daea7da03d452c50a8caa4c2d152407ffd0c02201f121b0e99df1d30f4757b6a00b8d974d70996771893ac49c4a245c147cc1d8f160466a90248202b7472757374636861696e2d6261636b656e642e6170692e6177732e7374672e6c64672d746563682e636f6d320121332103cb7628e7248ddf9c07da54b979f16bf081fb3d173aac0992ad2a44ef6a388ae2600401000000", +}; + +function createTrustChain(device: Device): Promise { + return StreamTree.createNewTree(device); +} + +function addMember( + device: Device, + path: string, + name: keyof typeof MOCK_DATA.members, +): (tree: StreamTree) => Promise { + const memberId = crypto.from_hex(MOCK_DATA.members[name].pubkey); + return (tree: StreamTree) => tree.share(path, device, memberId, name, Permissions.OWNER); +} + +function jsonRequestContent( + mock: jest.Mock]>, +): Promise { + return Promise.all(mock.mock.calls.map(([request]) => request.json())); +} diff --git a/libs/trustchain/src/api.ts b/libs/trustchain/src/api.ts index e560acf75f27..065ec364f88e 100644 --- a/libs/trustchain/src/api.ts +++ b/libs/trustchain/src/api.ts @@ -59,7 +59,7 @@ export type TrustchainResponse = { [key: string]: string; }; -type PutCommandsRequest = { +export type PutCommandsRequest = { path: string; blocks: string[]; }; diff --git a/libs/trustchain/src/sdk.ts b/libs/trustchain/src/sdk.ts index 414f856f7894..67a2b570ae6f 100644 --- a/libs/trustchain/src/sdk.ts +++ b/libs/trustchain/src/sdk.ts @@ -14,9 +14,7 @@ import { import { crypto, Challenge, - CommandStream, CommandStreamEncoder, - CommandStreamDecoder, StreamTree, Permissions, DerivationPath, @@ -337,10 +335,7 @@ export class SDK implements TrustchainSDK { private async fetchTrustchain(jwt: JWT, trustchainId: string) { const trustchainData = await this.api.getTrustchain(jwt, trustchainId); - const streams = Object.values(trustchainData).map( - data => new CommandStream(CommandStreamDecoder.decode(crypto.from_hex(data))), - ); - const streamTree = StreamTree.from(...streams); + const streamTree = StreamTree.deserialize(trustchainData); return { streamTree }; } diff --git a/libs/trustchain/src/simpleEncryption.sdk.test.ts b/libs/trustchain/src/simpleEncryption.sdk.test.ts deleted file mode 100644 index 434ae63a3150..000000000000 --- a/libs/trustchain/src/simpleEncryption.sdk.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EMPTY } from "rxjs"; -import { crypto } from "@ledgerhq/hw-trustchain"; -import { getSdk } from "."; -import { getEnv } from "@ledgerhq/live-env"; - -test("encryptUserData + decryptUserData", async () => { - const sdk = getSdk( - false, - { - applicationId: 16, - name: "test", - apiBaseUrl: getEnv("TRUSTCHAIN_API_STAGING"), - }, - () => () => EMPTY, - ); - const obj = new Uint8Array([1, 2, 3, 4, 5]); - const keypair = await crypto.randomKeypair(); - const trustchain = { - rootId: "", - walletSyncEncryptionKey: crypto.to_hex(keypair.privateKey), - applicationPath: "m/0'/16'/0'", - }; - const encrypted = await sdk.encryptUserData(trustchain, obj); - const decrypted = await sdk.decryptUserData(trustchain, encrypted); - expect(decrypted).toEqual(obj); -});