diff --git a/.changeset/fresh-passports-scan.md b/.changeset/fresh-passports-scan.md new file mode 100644 index 0000000000..e7a8716748 --- /dev/null +++ b/.changeset/fresh-passports-scan.md @@ -0,0 +1,6 @@ +--- +"@caravan/wallets": minor +--- + +Add BC-UR extended-public-key decoding for Passport Sparrow multisig exports +and SeedSigner keys on test-family networks. diff --git a/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts new file mode 100644 index 0000000000..e22ba8c1e3 --- /dev/null +++ b/packages/caravan-wallets/src/bcur2/__tests__/extendedPublicKeyDecoder.test.ts @@ -0,0 +1,213 @@ +import { ExtendedPublicKey, Network } from "@caravan/bitcoin"; +import { + Bytes, + CryptoHDKey, + CryptoKeypath, + CryptoPSBT, + PathComponent, + RegistryItem, + URRegistryDecoder, +} from "@keystonehq/bc-ur-registry"; + +import { BCUR2Decoder } from "../decoder"; +import { + BCUR2ExtendedPublicKeyDecoder, + PASSPORT_MULTISIG_EXPORT_ERROR, +} from "../extendedPublicKeyDecoder"; + +const PASSPORT_MULTISIG_EXPORT = { + p2sh_deriv: "m/45'", + p2sh: "tpubDA2HtQKGFGx9BPZQ3yemoxaH6tjBkKbwTc4mMqpvkvu2RSkmKgADtCVaCpV4iDhXnqb46iQ7PjMMVzU6MERq7tNoLJ8rEvaYSStJssFKfvb", + p2sh_p2wsh_deriv: "m/48'/1'/0'/1'", + p2sh_p2wsh: + "Upub5SRh9Zozi9attVLrU2hezfntv3kUNwbeRa3zJr6aV1pnmEUtfgZnLszfFKJyULFMbEcogARAsKosJBgaN8AmotgvbVgJ78srDmj59wzuTP7", + p2wsh_deriv: "m/48'/1'/0'/2'", + p2wsh: + "Vpub5mFxTEUurq8NoPJfC1T9dCYvSYrorqSRnWgCjbEBwZ2coBhBej9f3TK6tbz5m27sVn4TAY2KsbmN1k2oi2J2NcWJabKGSHdgXxJaJ4V8YMb", + xfp: "EFA5D916", +}; + +// SeedSigner 0.8.7 testnet vector from tests/test_encodepsbtqr.py. +const SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT = [ + "UR:CRYPTO-ACCOUNT/1-5/LPADAHCSKECYRTPEDKMOHDCFOEADCYSSMECPONAOLYTAADMETAADDLONAXHDCLAOKSRLNLKPUENSAHBTHS", + "UR:CRYPTO-ACCOUNT/2-5/LPAOAHCSKECYRTPEDKMOHDCFGYATHPMNSNKKGHZMLUZORPVDGUOTECSTTKTOLPCWPTNTLKZTTIZTNDJSCF", + "UR:CRYPTO-ACCOUNT/3-5/LPAXAHCSKECYRTPEDKMOHDCFZTBEAAHDCXVDTPMYRSTDSPZSBZSPGERLGDATUYNLPYBTGYIYYKBDFGWPKE", + "UR:CRYPTO-ACCOUNT/4-5/LPAAAHCSKECYRTPEDKMOHDCFBTWTAOSWKSVTSGCHBYDKYAVDAHTAADEHOYAOADAMTAADDYOTADGYBKBWFE", + "UR:CRYPTO-ACCOUNT/5-5/LPAHAHCSKECYRTPEDKMOHDCFLOCSDYYKADYKAEYKAOYKAOCYSSMECPONAXAAAYCYIOREKKJKAETODLFYWP", +]; + +const SEEDSIGNER_TESTNET_XPUB = + "tpubDEfkEY1bXf2FvRVCxiMRXWZPrEaxkMdwoVnjWhGnP42kk2ZPfkB86p5rLEjAVc7YgVGuUQWPPo6mbwTt9qXEW4YUyQXkkpQ5uJdppanC7rL"; + +function makeHDKey(): CryptoHDKey { + const origin = new CryptoKeypath( + [new PathComponent({ index: 45, hardened: true })], + Buffer.from("efa5d916", "hex"), + 1, + ); + + return new CryptoHDKey({ + isMaster: false, + key: Buffer.from( + "039b9ba1ad522fa2c4fc550c23626c2fb352373e22fbb8d59b984d058affe97e18", + "hex", + ), + chainCode: Buffer.from( + "6ff1bd910b424c55d269864c9f9ee9e3a5b20b034999ba83cb17f5635f56077c", + "hex", + ), + origin, + parentFingerprint: Buffer.from("efa5d916", "hex"), + }); +} + +function scan( + item: RegistryItem, + network = Network.TESTNET, +): BCUR2ExtendedPublicKeyDecoder { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); + item + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => decoder.receivePart(fragment)); + return decoder; +} + +function scanPassport( + value: unknown, + network = Network.TESTNET, +): BCUR2ExtendedPublicKeyDecoder { + return scan(new Bytes(Buffer.from(JSON.stringify(value))), network); +} + +describe("BCUR2ExtendedPublicKeyDecoder", () => { + it.each([Network.TESTNET, Network.REGTEST, Network.SIGNET])( + "decodes SeedSigner's testnet account on %s", + (network) => { + const decoder = new BCUR2ExtendedPublicKeyDecoder({ network }); + SEEDSIGNER_TESTNET_CRYPTO_ACCOUNT.forEach((fragment) => + decoder.receivePart(fragment), + ); + + expect(decoder.getDecodedData()).toEqual({ + type: "crypto-account", + xpub: SEEDSIGNER_TESTNET_XPUB, + rootFingerprint: "C49122A5", + bip32Path: "48'/1'/0'/2'", + }); + expect(decoder.getError()).toBeNull(); + }, + ); + + it("uses test-family serialization for a standalone HD key on regtest", () => { + const decoder = scan(makeHDKey(), Network.REGTEST); + + expect(decoder.getDecodedData()).toEqual({ + type: "crypto-hdkey", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "EFA5D916", + bip32Path: "45'", + }); + }); + + it("decodes Passport's bytes export as its source key", () => { + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT); + + expect(decoder.getDecodedData()).toEqual({ + type: "bytes", + xpub: PASSPORT_MULTISIG_EXPORT.p2sh, + rootFingerprint: "efa5d916", + bip32Path: "45'", + }); + expect(decoder.getError()).toBeNull(); + }); + + it("accepts Passport's historical wrapped-SegWit field alias", () => { + const { p2sh_p2wsh_deriv, p2sh_p2wsh, ...rest } = PASSPORT_MULTISIG_EXPORT; + const decoder = scanPassport({ + ...rest, + p2wsh_p2sh_deriv: p2sh_p2wsh_deriv, + p2wsh_p2sh: p2sh_p2wsh, + }); + + expect(decoder.getDecodedData()?.xpub).toBe(PASSPORT_MULTISIG_EXPORT.p2sh); + }); + + it.each([ + ["arbitrary bytes", { hello: "world" }], + [ + "the wrong source path", + { ...PASSPORT_MULTISIG_EXPORT, p2sh_deriv: "m/84'/1'/0'" }, + ], + [ + "a mismatched fingerprint", + { ...PASSPORT_MULTISIG_EXPORT, xfp: "00000000" }, + ], + ])("rejects %s", (_label, value) => { + const decoder = scanPassport(value); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); + }); + + it("rejects a key from the wrong serialization family", () => { + const decoder = scanPassport(PASSPORT_MULTISIG_EXPORT, Network.MAINNET); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); + }); + + it("rejects a key that does not represent m/45'", () => { + const key = ExtendedPublicKey.fromBase58(PASSPORT_MULTISIG_EXPORT.p2sh); + key.index = 0x8000002e; + const decoder = scanPassport({ + ...PASSPORT_MULTISIG_EXPORT, + p2sh: key.toBase58(), + }); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe(PASSPORT_MULTISIG_EXPORT_ERROR); + }); + + it("rejects PSBTs in the key-only decoder", () => { + const decoder = scan(new CryptoPSBT(Buffer.from([1, 2, 3]))); + + expect(decoder.getDecodedData()).toBeNull(); + expect(decoder.getError()).toBe( + "QR code contains a PSBT, not an extended public key.", + ); + }); + + it("keeps bytes unsupported in the generic decoder", () => { + const item = new Bytes( + Buffer.from(JSON.stringify(PASSPORT_MULTISIG_EXPORT)), + ); + const decoder = new BCUR2Decoder(); + item + .toUREncoder(100) + .encodeWhole() + .forEach((fragment) => decoder.receivePart(fragment)); + + expect(decoder.getDecodedData(Network.TESTNET)).toBeNull(); + expect(decoder.getError()).toBe("Unsupported UR type: bytes"); + }); + + it("decodes only the supplied CBOR view", () => { + const cbor = makeHDKey().toCBOR(); + const backing = Buffer.alloc(cbor.byteLength + 8, 0xff); + cbor.copy(backing, 4); + const registryDecoder = { + isComplete: () => true, + getProgress: () => 1, + resultUR: () => ({ + type: "crypto-hdkey", + cbor: backing.subarray(4, 4 + cbor.byteLength), + }), + } as unknown as URRegistryDecoder; + const decoder = new BCUR2Decoder(registryDecoder); + + expect(decoder.getDecodedData(Network.TESTNET)?.xpub).toBe( + PASSPORT_MULTISIG_EXPORT.p2sh, + ); + }); +}); diff --git a/packages/caravan-wallets/src/bcur2/decoder.ts b/packages/caravan-wallets/src/bcur2/decoder.ts index 24947bebd9..8f2ea02c8d 100644 --- a/packages/caravan-wallets/src/bcur2/decoder.ts +++ b/packages/caravan-wallets/src/bcur2/decoder.ts @@ -52,7 +52,7 @@ export interface ExtendedPublicKeyData { * - crypto-hdkey: Contains hierarchical deterministic keys */ export class BCUR2Decoder { - private decoder: URRegistryDecoder; + protected decoder: URRegistryDecoder; private error: string | null = null; @@ -254,7 +254,7 @@ export class BCUR2Decoder { const decodedResult = this.handleDecodedResult( result.type, - Buffer.from(result.cbor.buffer), + Buffer.from(result.cbor), network, ); diff --git a/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts new file mode 100644 index 0000000000..f1d8f0c96a --- /dev/null +++ b/packages/caravan-wallets/src/bcur2/extendedPublicKeyDecoder.ts @@ -0,0 +1,168 @@ +import { + BitcoinNetwork, + ExtendedPublicKey, + EXTENDED_PUBLIC_KEY_VERSIONS, + fingerprintToFixedLengthHex, + Network, + validatePublicKey, +} from "@caravan/bitcoin"; +import { Bytes } from "@keystonehq/bc-ur-registry"; + +import { ColdcardExportExtendedPublicKey } from "../coldcard"; + +import { BCUR2Decoder, ExtendedPublicKeyData } from "./decoder"; +import { bip32SerializationNetwork } from "./utils"; + +const PASSPORT_MULTISIG_PATH = "m/45'"; +const PASSPORT_MULTISIG_INDEX = 0x8000002d; +const FINGERPRINT_PATTERN = /^[0-9a-f]{8}$/i; + +export const PASSPORT_MULTISIG_EXPORT_ERROR = + "The BC-UR bytes payload is not a compatible Passport multisig key export."; + +export interface BCUR2ExtendedPublicKeyDecoderOptions { + network?: BitcoinNetwork; +} + +type PassportMultisigExport = Record & { + p2sh_deriv: typeof PASSPORT_MULTISIG_PATH; + p2sh: string; + p2wsh_deriv: string; + p2wsh: string; + xfp: string; +}; + +function hasStringPair( + data: Record, + derivationField: string, + keyField: string, +): boolean { + return ( + typeof data[derivationField] === "string" && + Boolean(data[derivationField]) && + typeof data[keyField] === "string" && + Boolean(data[keyField]) + ); +} + +function isPassportMultisigExport( + value: unknown, +): value is PassportMultisigExport { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const data = value as Record; + const hasWrappedSegwitKey = + hasStringPair(data, "p2sh_p2wsh_deriv", "p2sh_p2wsh") || + hasStringPair(data, "p2wsh_p2sh_deriv", "p2wsh_p2sh"); + + return ( + data.p2sh_deriv === PASSPORT_MULTISIG_PATH && + typeof data.p2sh === "string" && + hasStringPair(data, "p2wsh_deriv", "p2wsh") && + hasWrappedSegwitKey && + typeof data.xfp === "string" && + FINGERPRINT_PATTERN.test(data.xfp) + ); +} + +/** + * Decodes extended public keys from standard BC-UR registry items and from + * Passport's Coldcard-compatible multisig JSON wrapped in `ur:bytes`. + * Generic BC-UR decoding intentionally continues to reject `bytes`. + */ +export class BCUR2ExtendedPublicKeyDecoder extends BCUR2Decoder { + private readonly network: BitcoinNetwork; + + private keyError: string | null = null; + + constructor({ + network = Network.MAINNET, + }: BCUR2ExtendedPublicKeyDecoderOptions = {}) { + super(); + this.network = network; + } + + getError(): string | null { + return this.keyError || super.getError(); + } + + private decodePassportBytes( + cbor: Uint8Array, + network: BitcoinNetwork, + ): ExtendedPublicKeyData { + const bytes = Bytes.fromCBOR(Buffer.from(cbor)).getData(); + + let value: unknown; + try { + value = JSON.parse(bytes.toString("utf8")); + } catch (_error) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } + + if (!isPassportMultisigExport(value)) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } + + const serializationNetwork = bip32SerializationNetwork(network); + const expectedVersion = + serializationNetwork === Network.MAINNET + ? EXTENDED_PUBLIC_KEY_VERSIONS.xpub + : EXTENDED_PUBLIC_KEY_VERSIONS.tpub; + + let key: ExtendedPublicKey; + try { + key = ExtendedPublicKey.fromBase58(value.p2sh); + } catch (_error) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } + + if ( + key.version !== expectedVersion || + validatePublicKey(key.pubkey) || + key.depth !== 1 || + key.index !== PASSPORT_MULTISIG_INDEX || + typeof key.parentFingerprint !== "number" || + fingerprintToFixedLengthHex(key.parentFingerprint).toLowerCase() !== + value.xfp.toLowerCase() + ) { + throw new Error(PASSPORT_MULTISIG_EXPORT_ERROR); + } + + const parsed = new ColdcardExportExtendedPublicKey({ + network: serializationNetwork, + bip32Path: PASSPORT_MULTISIG_PATH, + }).parse(value); + + return { + type: "bytes", + xpub: parsed.xpub, + rootFingerprint: parsed.rootFingerprint, + bip32Path: parsed.bip32Path.replace(/^m\//, ""), + }; + } + + getDecodedData( + network: BitcoinNetwork = this.network, + ): ExtendedPublicKeyData | null { + if (!this.decoder.isComplete()) return null; + + try { + const result = this.decoder.resultUR(); + + if (result.type === "bytes") { + return this.decodePassportBytes(result.cbor, network); + } + if (result.type === "crypto-psbt") { + throw new Error("QR code contains a PSBT, not an extended public key."); + } + + const decoded = super.getDecodedData(bip32SerializationNetwork(network)); + return typeof decoded === "string" ? null : decoded; + } catch (error: unknown) { + this.keyError = error instanceof Error ? error.message : String(error); + return null; + } + } +} diff --git a/packages/caravan-wallets/src/bcur2/index.ts b/packages/caravan-wallets/src/bcur2/index.ts index b3affb44b6..0abc8144c7 100644 --- a/packages/caravan-wallets/src/bcur2/index.ts +++ b/packages/caravan-wallets/src/bcur2/index.ts @@ -1,3 +1,4 @@ export * from "./interactions"; export * from "./decoder"; +export * from "./extendedPublicKeyDecoder"; export * from "./encoder"; diff --git a/packages/caravan-wallets/src/bcur2/utils.ts b/packages/caravan-wallets/src/bcur2/utils.ts index 1d1c88fdf5..a0ea28cf7c 100644 --- a/packages/caravan-wallets/src/bcur2/utils.ts +++ b/packages/caravan-wallets/src/bcur2/utils.ts @@ -1,8 +1,17 @@ -import { ExtendedPublicKey, BitcoinNetwork } from "@caravan/bitcoin"; +import { ExtendedPublicKey, BitcoinNetwork, Network } from "@caravan/bitcoin"; import { CryptoHDKey, CryptoAccount } from "@keystonehq/bc-ur-registry"; import { ExtendedPublicKeyData } from "./decoder"; +/** + * BIP32 extended keys have only mainnet and testnet serialization families. + */ +export function bip32SerializationNetwork( + network: BitcoinNetwork +): Network.MAINNET | Network.TESTNET { + return network === Network.MAINNET ? Network.MAINNET : Network.TESTNET; +} + /** * Process a CBOR buffer containing crypto-account data and extract extended public key information */ @@ -79,7 +88,7 @@ function processHDKey( chaincode: chainCode.toString("hex"), pubkey: key.toString("hex"), parentFingerprint: parentFp.readUInt32BE(0), - network, + network: bip32SerializationNetwork(network), }); const xpub = xpubObj.toBase58();