From fe39d19116ca219de7663dfda5d1050fbe2a1ae0 Mon Sep 17 00:00:00 2001 From: jdabbech-ledger Date: Tue, 14 Jan 2025 09:44:06 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20(signer-btc):=20Handle=20Musig=20ps?= =?UTF-8?q?bt=20signatures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app-binder/SignPsbtDeviceActionTypes.ts | 2 +- .../signer-btc/src/api/model/Signature.ts | 32 +++ .../SignMessageDeviceAction.test.ts | 4 +- .../SignPsbt/SignPsbtDeviceAction.test.ts | 8 +- .../SignPsbt/SignPsbtDeviceAction.ts | 6 +- .../app-binder/task/SignPsbtTask.test.ts | 226 +++++++++++++++- .../internal/app-binder/task/SignPsbtTask.ts | 249 ++++++++++++++++-- 7 files changed, 484 insertions(+), 43 deletions(-) diff --git a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts index 34c0ceb7b..51d62c768 100644 --- a/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts +++ b/packages/signer/signer-btc/src/api/app-binder/SignPsbtDeviceActionTypes.ts @@ -8,10 +8,10 @@ import { } from "@ledgerhq/device-management-kit"; import { type Psbt } from "@api/model/Psbt"; +import { type PsbtSignature } from "@api/model/Signature"; import { type Wallet as ApiWallet } from "@api/model/Wallet"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { type BuildPsbtTaskResult } from "@internal/app-binder/task/BuildPsbtTask"; -import { type PsbtSignature } from "@internal/app-binder/task/SignPsbtTask"; import { type DataStoreService } from "@internal/data-store/service/DataStoreService"; import { type PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; diff --git a/packages/signer/signer-btc/src/api/model/Signature.ts b/packages/signer/signer-btc/src/api/model/Signature.ts index 8a7368dd4..5858d60ec 100644 --- a/packages/signer/signer-btc/src/api/model/Signature.ts +++ b/packages/signer/signer-btc/src/api/model/Signature.ts @@ -1,3 +1,35 @@ import { type HexaString } from "@ledgerhq/device-management-kit"; export type Signature = { r: HexaString; s: HexaString; v: number }; + +export type PartialSignature = { + inputIndex: number; + pubkey: Uint8Array; + signature: Uint8Array; + tapleafHash?: Uint8Array; +}; + +export type MusigPubNonce = { + inputIndex: number; + participantPubkey: Uint8Array; + aggregatedPubkey: Uint8Array; + tapleafHash?: Uint8Array; + pubnonce: Uint8Array; +}; + +export type MusigPartialSignature = { + inputIndex: number; + participantPubkey: Uint8Array; + aggregatedPubkey: Uint8Array; + tapleafHash?: Uint8Array; + partialSignature: Uint8Array; +}; + +export type PsbtSignature = + | PartialSignature + | MusigPartialSignature + | MusigPubNonce; + +export const isPartialSignature = ( + psbtSignature: PsbtSignature, +): psbtSignature is PartialSignature => "pubkey" in psbtSignature; diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts index bcdabe776..cf3c24c6a 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignMessage/SignMessageDeviceAction.test.ts @@ -101,9 +101,10 @@ describe("SignMessageDeviceAction", () => { deviceAction, expectedStates, makeDeviceActionInternalApiMock(), + done, ); - // Verify mocks calls parameters + // @todo Put this in a onDone handle of testDeviceActionStates observable.subscribe({ complete: () => { expect(signPersonalMessageMock).toHaveBeenCalledWith( @@ -115,7 +116,6 @@ describe("SignMessageDeviceAction", () => { }, }), ); - done(); }, }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts index eab133ab6..528f17fd5 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.test.ts @@ -81,7 +81,7 @@ describe("SignPsbtDeviceAction", () => { data: [ { inputIndex: 0, - pubKeyAugmented: Uint8Array.from([0x04, 0x05, 0x06]), + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), signature: Uint8Array.from([0x01, 0x02, 0x03]), }, ], @@ -125,7 +125,7 @@ describe("SignPsbtDeviceAction", () => { output: [ { inputIndex: 0, - pubKeyAugmented: Uint8Array.from([0x04, 0x05, 0x06]), + pubkey: Uint8Array.from([0x04, 0x05, 0x06]), signature: Uint8Array.from([0x01, 0x02, 0x03]), }, ], @@ -137,9 +137,10 @@ describe("SignPsbtDeviceAction", () => { deviceAction, expectedStates, makeDeviceActionInternalApiMock(), + done, ); - // Verify mocks calls parameters + // @todo Put this in a onDone handle of testDeviceActionStates observable.subscribe({ complete: () => { expect(prepareWalletPolicyMock).toHaveBeenCalledWith( @@ -167,7 +168,6 @@ describe("SignPsbtDeviceAction", () => { }, }), ); - done(); }, }); }); diff --git a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts index 1216b1c26..7d0aa66bf 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/device-action/SignPsbt/SignPsbtDeviceAction.ts @@ -20,6 +20,7 @@ import { type SignPsbtDAOutput, } from "@api/app-binder/SignPsbtDeviceActionTypes"; import { type Psbt as ApiPsbt } from "@api/model/Psbt"; +import { type PsbtSignature } from "@api/model/Signature"; import { type Wallet as ApiWallet } from "@api/model/Wallet"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { @@ -27,10 +28,7 @@ import { type BuildPsbtTaskResult, } from "@internal/app-binder/task/BuildPsbtTask"; import { PrepareWalletPolicyTask } from "@internal/app-binder/task/PrepareWalletPolicyTask"; -import { - type PsbtSignature, - SignPsbtTask, -} from "@internal/app-binder/task/SignPsbtTask"; +import { SignPsbtTask } from "@internal/app-binder/task/SignPsbtTask"; import type { DataStoreService } from "@internal/data-store/service/DataStoreService"; import type { PsbtMapper } from "@internal/psbt/service/psbt/PsbtMapper"; import type { ValueParser } from "@internal/psbt/service/value/ValueParser"; diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts index 611091f04..50272e271 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.test.ts @@ -15,7 +15,7 @@ import { type ValueParser } from "@internal/psbt/service/value/ValueParser"; import { type Wallet } from "@internal/wallet/model/Wallet"; import { type WalletSerializer } from "@internal/wallet/service/WalletSerializer"; -const SIGN_PSBT_YIELD_RESULT = Uint8Array.from([ +const SIGN_PSBT_YIELD_PARIAL_SIG_RESULT = Uint8Array.from([ 0x00, 0x20, 0xf1, 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, @@ -26,9 +26,40 @@ const SIGN_PSBT_YIELD_RESULT = Uint8Array.from([ 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, ]); +const SIGN_PSBT_YIELD_MUSIG_PARIAL_SIG_RESULT = Uint8Array.from([ + 0xff, 0xfe, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x42, 0x44, + 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, + 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, + 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, + 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, + 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, + 0xf8, 0x0a, 0x8f, 0x11, 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, + 0xd2, 0xfe, 0xee, 0x33, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, + 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, + 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, +]); + +const SIGN_PSBT_YIELD_MUSIG_PUB_NONCE_RESULT = Uint8Array.from([ + 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0x42, 0x44, + 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, + 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, + 0x5f, 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, + 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x01, 0x4d, 0x63, + 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, + 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, + 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, + 0x9d, 0xcc, 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, 0x50, + 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, 0xfe, 0xee, 0x33, 0x4d, + 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, + 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, + 0xbd, 0x9a, 0x01, 0x66, 0x3c, +]); + describe("SignPsbtTask", () => { describe("run", () => { - it("should return signatures", async () => { + it("should return partial signatures", async () => { // given const api = { sendCommand: jest.fn(), @@ -58,7 +89,7 @@ describe("SignPsbtTask", () => { data: [], }), ), - getYieldedResults: () => [SIGN_PSBT_YIELD_RESULT], + getYieldedResults: () => [SIGN_PSBT_YIELD_PARIAL_SIG_RESULT], }) as unknown as ContinueTask; // when @@ -91,7 +122,7 @@ describe("SignPsbtTask", () => { data: [ { inputIndex: 0, - pubKeyAugmented: Uint8Array.from([ + pubkey: Uint8Array.from([ 0xf1, 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, @@ -111,6 +142,193 @@ describe("SignPsbtTask", () => { }), ); }); + it("should return musig partial signatures", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: () => Maybe.of(Uint8Array.from([0x03])), + } as unknown as Psbt; + const wallet = { + hmac: Uint8Array.from([0x04]), + } as Wallet; + const psbtCommitment = { + globalCommitment: Uint8Array.from([0x03]), + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + } as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + data: [], + }), + ), + getYieldedResults: () => [SIGN_PSBT_YIELD_MUSIG_PARIAL_SIG_RESULT], + }) as unknown as ContinueTask; + + // when + const signatures = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitment: Uint8Array.from([0x03]), + inputsCount: 42, + inputsRoot: Uint8Array.from([0x01]), + outputsCount: 42, + outputsRoot: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + expect(signatures).toStrictEqual( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + partialSignature: Uint8Array.from([ + 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, + 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, + 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, + 0x5f, 0x12, + ]), + participantPubkey: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, 0x59, + ]), + aggregatedPubkey: Uint8Array.from([ + 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, + 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, + 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, + 0xfe, 0xee, 0x33, + ]), + tapleafHash: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, + ]), + }, + ], + }), + ); + }); + it("should return musig pub nonce", async () => { + // given + const api = { + sendCommand: jest.fn(), + } as unknown as InternalApi; + const psbt = { + getGlobalValue: () => Maybe.of(Uint8Array.from([0x03])), + } as unknown as Psbt; + const wallet = { + hmac: Uint8Array.from([0x04]), + } as Wallet; + const psbtCommitment = { + globalCommitment: Uint8Array.from([0x03]), + inputsRoot: Uint8Array.from([0x01]), + outputsRoot: Uint8Array.from([0x02]), + } as PsbtCommitment; + const dataStore = {} as DataStore; + const walletSerializer = { + getId: jest.fn(() => Uint8Array.from([0x05])), + } as unknown as WalletSerializer; + const valueParser = { + getVarint: jest.fn(() => Maybe.of(42)), + } as unknown as ValueParser; + const continueTaskFactory = () => + ({ + run: jest.fn().mockResolvedValue( + CommandResultFactory({ + data: [], + }), + ), + getYieldedResults: () => [SIGN_PSBT_YIELD_MUSIG_PUB_NONCE_RESULT], + }) as unknown as ContinueTask; + + // when + const signatures = await new SignPsbtTask( + api, + { + psbt, + wallet, + psbtCommitment, + dataStore, + }, + walletSerializer, + valueParser, + continueTaskFactory, + ).run(); + // then + expect(api.sendCommand).toHaveBeenCalledWith( + new SignPsbtCommand({ + globalCommitment: Uint8Array.from([0x03]), + inputsCount: 42, + inputsRoot: Uint8Array.from([0x01]), + outputsCount: 42, + outputsRoot: Uint8Array.from([0x02]), + walletId: Uint8Array.from([0x05]), + walletHmac: Uint8Array.from([0x04]), + }), + ); + expect(signatures).toStrictEqual( + CommandResultFactory({ + data: [ + { + inputIndex: 0, + pubnonce: Uint8Array.from([ + 0xe8, 0x42, 0x44, 0x7f, 0xae, 0x7b, 0x1c, 0x6e, 0xb7, 0xa8, + 0xa7, 0x85, 0xf7, 0x76, 0xfa, 0x19, 0xa9, 0x3a, 0xb9, 0x6c, + 0xc1, 0xee, 0xee, 0xe9, 0x47, 0xc1, 0x71, 0x13, 0x38, 0x5f, + 0x5f, 0x12, 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, + 0x7b, 0xe2, 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, + 0xdf, 0xb0, 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, + 0x9a, 0x01, 0x66, 0x3c, 0x59, 0x01, + ]), + participantPubkey: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, 0x59, + ]), + aggregatedPubkey: Uint8Array.from([ + 0x59, 0x41, 0x13, 0xe5, 0x71, 0x00, 0x06, 0x3d, 0x9d, 0xcc, + 0xd7, 0x8f, 0xb3, 0x93, 0x82, 0xdb, 0xf8, 0x0a, 0x8f, 0x11, + 0x50, 0xfd, 0x59, 0xd9, 0xfe, 0xb7, 0x9e, 0x25, 0x3b, 0xd2, + 0xfe, 0xee, 0x33, + ]), + tapleafHash: Uint8Array.from([ + 0x4d, 0x63, 0x5c, 0xf2, 0x52, 0xae, 0x26, 0xa6, 0x7b, 0xe2, + 0x77, 0x71, 0x2e, 0xad, 0x07, 0xb4, 0x48, 0x96, 0xdf, 0xb0, + 0x16, 0xfc, 0x9d, 0x03, 0xa3, 0xe9, 0x22, 0xbd, 0x9a, 0x01, + 0x66, 0x3c, + ]), + }, + ], + }), + ); + }); }); describe("errors", () => { it("should return an error if continue task fails", async () => { diff --git a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts index aa9f4255b..4aeb22498 100644 --- a/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts +++ b/packages/signer/signer-btc/src/internal/app-binder/task/SignPsbtTask.ts @@ -3,10 +3,17 @@ import { type CommandResult, CommandResultFactory, type InternalApi, + InvalidStatusWordError, isSuccessCommandResult, } from "@ledgerhq/device-management-kit"; -import { Maybe } from "purify-ts"; +import { Either, Left, Maybe, Right } from "purify-ts"; +import { + type MusigPartialSignature, + type MusigPubNonce, + type PartialSignature, + type PsbtSignature, +} from "@api/model/Signature"; import { SignPsbtCommand } from "@internal/app-binder/command/SignPsbtCommand"; import { type BtcErrorCodes } from "@internal/app-binder/command/utils/bitcoinAppErrors"; import { type BuildPsbtTaskResult } from "@internal/app-binder/task/BuildPsbtTask"; @@ -22,14 +29,17 @@ export type SignPsbtTaskArgs = BuildPsbtTaskResult & { wallet: InternalWallet; }; -export type PsbtSignature = { - inputIndex: number; - pubKeyAugmented: Uint8Array; - signature: Uint8Array; -}; - export type SignPsbtTaskResult = CommandResult; +const MUSIG_PUBNONCE_TAG = 0xffffffff; +const MUSIG_PARTIAL_SIGNATURE_TAG = 0xfffffffe; +const PARTIAL_SIGNATURE_MAX_TAG = 0xffff; +const PUBKEY_LENGTH = 32; +const PUBKEY_LENGTH_COMPRESSED = 33; +const PUBKEY_LENGTH_TAPLEAF = 64; +const PARTIAL_SIGNATURE_LENGTH = 32; +const PUBNONCE_LENGTH = 66; + export class SignPsbtTask { constructor( private readonly _api: InternalApi, @@ -42,6 +52,19 @@ export class SignPsbtTask { ) => new ContinueTask(api, dataStore), ) {} + /** + * Executes the task of signing a PSBT (Partially Signed Bitcoin Transaction) by processing + * the necessary PSBT components, sending the signing command, and handling the responses. + * + * The method first extracts the required data from the PSBT and wallet arguments, sends a signing + * PSBT command using these details, and continues the task execution using the resulting command output. + * + * If the command results in success, it decodes the returned signatures, performing error handling for failures + * in decoding. Finally, it returns the successfully decoded signatures or an error response. + * + * @return {Promise} A promise that resolves with the result of the PSBT signing process. + * This can either be a success object containing decoded PSBT signatures or an error result. + */ async run(): Promise { const { psbtCommitment: { globalCommitment, inputsRoot, outputsRoot }, @@ -71,28 +94,198 @@ export class SignPsbtTask { const result = await continueTask.run(signPsbtCommandResult); if (isSuccessCommandResult(result)) { - const signatureList = continueTask.getYieldedResults(); - const signatures = signatureList.map((sig) => { - const parser = new ByteArrayParser(sig); - const inputIndex = extractVarint(parser).mapOrDefault( - (val) => val.value, - 0, - ); - const pubKeyAugmentedLength = Maybe.fromNullable( - parser.extract8BitUInt(), - ).orDefault(0); - const pubKeyAugmented = Maybe.fromNullable( - parser.extractFieldByLength(pubKeyAugmentedLength), - ).orDefault(Uint8Array.from([])); - const signature = Maybe.fromNullable( - parser.extractFieldByLength(parser.getUnparsedRemainingLength()), - ).orDefault(Uint8Array.from([])); - return { signature, inputIndex, pubKeyAugmented }; - }); - return CommandResultFactory({ - data: signatures, - }); + const encodedSignatures = continueTask.getYieldedResults(); + const decodedSignatures: PsbtSignature[] = []; + // decode yielded signatures + for (const encodedSignature of encodedSignatures) { + const decodedSignature = this._decodePsbtSignature(encodedSignature); + if (decodedSignature.isLeft()) { + return CommandResultFactory({ + error: decodedSignature.extract(), + }); + } else if (decodedSignature.isRight()) { + decodedSignatures.push(decodedSignature.extract()); + } + } + return CommandResultFactory({ data: decodedSignatures }); } return result; } + + /** + * Decodes a PSBT (Partially Signed Bitcoin Transaction) signature from a given byte array input. + * It determines the type of signature or data present based on input tags and delegates processing + * to the appropriate decoding method. + * + * If inputOrTag is: + * - at most 0xffff then it's a partial signature (legacy, native segwit, taproot or nested segwit) https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-at-most-65535 + * - equal to 0xFFFFFFFF then it's a round 1 of musig2 protocol https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-more-than-65535 + * - equal to 0xFFFFFFFE then it's a round 2 of musig2 protocol https://github.com/LedgerHQ/app-bitcoin-new/blob/24bcdae8274fa9866a11db54a713d93d5467c819/doc/bitcoin.md#if-tag_or_input_index-is-more-than-65535 + * + * @param {Uint8Array} yieldedSignature - The byte array representing the PSBT signature to decode. + * @return {Either} - Either the decoded PSBT signature or an error if decoding fails. + */ + private _decodePsbtSignature( + yieldedSignature: Uint8Array, + ): Either { + const parser = new ByteArrayParser(yieldedSignature); + const inputIndexOrTagOrError = extractVarint(parser) + .map((val) => val.value) + .toEither(new InvalidStatusWordError("Invalid input index or tag")); + if (inputIndexOrTagOrError.isLeft()) { + return inputIndexOrTagOrError; + } + const inputIndexOrTag = inputIndexOrTagOrError.unsafeCoerce(); + + if (inputIndexOrTag === MUSIG_PUBNONCE_TAG) { + return this._decodeMusigPubNonce(parser); + } else if (inputIndexOrTag === MUSIG_PARTIAL_SIGNATURE_TAG) { + return this._decodeMusigPartialSignature(parser); + } else if (inputIndexOrTag <= PARTIAL_SIGNATURE_MAX_TAG) { + return this._decodePartialSignature(parser, inputIndexOrTag); + } + return Left( + new InvalidStatusWordError( + `Invalid input index or tag returned: ${inputIndexOrTag}`, + ), + ); + } + + /** + * Decodes a Musig public nonce from the provided byte stream parser. + * + * @param {ByteArrayParser} parser - The parser used to extract data fields from a byte array. + * @return {Either} An `Either` containing either: + * - `MusigPubNonce` object if decoding is successful, or + * - `InvalidStatusWordError` if any required field is missing or invalid. + */ + private _decodeMusigPubNonce( + parser: ByteArrayParser, + ): Either { + const inputIndex = extractVarint(parser).mapOrDefault( + (val) => val.value, + 0, + ); + const pubnonceOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBNONCE_LENGTH), + ).toEither(new InvalidStatusWordError("Pubnonce is missing")); + const participantPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Participant pubkey is missing")); + const aggregatedPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Aggregated pubkey is missing")); + const tapleafHash = parser.extractFieldByLength( + parser.getUnparsedRemainingLength(), + ); + return Either.sequence([ + pubnonceOrError, + participantPubkeyOrError, + aggregatedPubkeyOrError, + ]).map((values) => ({ + inputIndex, + pubnonce: values[0]!, + participantPubkey: values[1]!, + aggregatedPubkey: values[2]!, + tapleafHash, + })); + } + + /** + * Decodes a Musig partial signature from the given byte array parser. + * This involves extracting and validating the input index, partial signature, + * participant public key, aggregated public key, and the optional tapleaf hash. + * + * @param {ByteArrayParser} parser The parser to extract the Musig partial signature data from. + * @return {Either} + * Returns an `Either` containing the decoded Musig partial signature on success or + * an `InvalidStatusWordError` if any required component is missing or invalid. + */ + private _decodeMusigPartialSignature( + parser: ByteArrayParser, + ): Either { + const inputIndex = extractVarint(parser).mapOrDefault( + (val) => val.value, + 0, + ); + const partialSignatureOrError = Maybe.fromNullable( + parser.extractFieldByLength(PARTIAL_SIGNATURE_LENGTH), + ).toEither(new InvalidStatusWordError("Partial signature is missing")); + const participantPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Participant pubkey is missing")); + const aggregatedPubkeyOrError = Maybe.fromNullable( + parser.extractFieldByLength(PUBKEY_LENGTH_COMPRESSED), + ).toEither(new InvalidStatusWordError("Aggregated pubkey is missing")); + const tapleafHash = parser.extractFieldByLength( + parser.getUnparsedRemainingLength(), + ); + return Either.sequence([ + partialSignatureOrError, + participantPubkeyOrError, + aggregatedPubkeyOrError, + ]).map((values) => ({ + inputIndex, + partialSignature: values[0]!, + participantPubkey: values[1]!, + aggregatedPubkey: values[2]!, + tapleafHash, + })); + } + + /** + * Decodes a partial signature from the provided parser, extracting the public key and signature data + * and validating their lengths based on the transaction type. + * + * @param {ByteArrayParser} parser - The parser instance used to extract data fields. + * @param {number} inputIndex - The index of the transaction input associated with the signature. + * @return {Either} Either an error if the decoding fails or the decoded partial signature object containing the input index, signature, public key, and optionally the tapleaf hash. + */ + private _decodePartialSignature( + parser: ByteArrayParser, + inputIndex: number, + ): Either { + const pubkeyOrError = Maybe.fromNullable(parser.extract8BitUInt()) + .toEither(new InvalidStatusWordError("Pubkey length is missing")) + .chain((pubKeyAugmentedLength) => { + return Maybe.fromNullable( + parser.extractFieldByLength(pubKeyAugmentedLength), + ).toEither(new InvalidStatusWordError("Pubkey is missing")); + }); + const signatureOrError = Maybe.fromNullable( + parser.extractFieldByLength(parser.getUnparsedRemainingLength()), + ).toEither(new InvalidStatusWordError("Signature is missing")); + return Either.sequence([pubkeyOrError, signatureOrError]).chain( + (values) => { + const pubkey = values[0]!; + const signature = values[1]!; + if (pubkey.length === PUBKEY_LENGTH_TAPLEAF) { + // tapscript spend: pubkey_augm is the concatenation of: + // - a 32-byte x-only pubkey + // - the 32-byte tapleaf_hash + return Right({ + inputIndex, + signature, + pubkey: pubkey.slice(0, PUBKEY_LENGTH), + tapleafHash: pubkey.slice(PUBKEY_LENGTH), + }); + } else if ( + [PUBKEY_LENGTH, PUBKEY_LENGTH_COMPRESSED].includes(pubkey.length) + ) { + // either legacy, segwit or taproot keypath spend + // pubkey must be 32 (taproot x-only pubkey) or 33 bytes (compressed pubkey) + return Right({ + inputIndex, + signature, + pubkey, + }); + } + return Left( + new InvalidStatusWordError( + `Invalid pubkey length returned: ${pubkey.length}`, + ), + ); + }, + ); + } }