Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
adafe6e
feat(wallets): add message-signing primitives (verifier, Entry, errors)
bucko13 May 21, 2026
797e4c8
feat(wallets): LedgerSignMessage returns Entry; factory threads expec…
bucko13 May 21, 2026
b203e2a
feat(wallets): TrezorSignMessage returns Entry; factory threads network
bucko13 May 21, 2026
dcf2d1a
feat(wallets): JadeSignMessage returns Entry; reconstructs BIP-137 v …
bucko13 May 21, 2026
cb07d48
feat(wallets): LedgerSignMessage handles v2 Bitcoin app + LEDGER_V2 k…
bucko13 May 21, 2026
c3828b3
feat(coordinator): add messageSigning test category to /#/test suite
bucko13 May 21, 2026
9815434
feat(wallets): BitBoxSignMessage with electrumSig65 normalization
bucko13 May 21, 2026
80ce35f
feat(wallets): ColdcardSignMessage (BIP-137 SD-card flow)
bucko13 May 21, 2026
fa6255b
docs+changeset: message-signing keystore support
bucko13 May 21, 2026
a9b363c
refactor(wallets): tighten message-signing API surface
bucko13 May 21, 2026
fc9a457
feat(wallets): wrap keystore SDK errors as MessageSigningError
bucko13 May 21, 2026
c5a948d
fix(wallets): harden Coldcard signed-message parser
bucko13 May 21, 2026
f7d64b2
test: cover Ledger v2 errors, BitBox regtest, fixture guard
bucko13 May 21, 2026
e2545a4
docs(changeset): align with refactor
bucko13 May 21, 2026
133e94d
docs(changeset): trim to changelog-appropriate length
bucko13 May 21, 2026
9e10c2b
feat: extract @caravan/messages package for signing protocol primitives
bucko13 May 21, 2026
cdf9ab5
chore(messages): mark @caravan/messages as private internal package
bucko13 May 21, 2026
9964464
refactor: rename Entry.expectedPubkey to Entry.pubkey
bucko13 May 21, 2026
238c81f
refactor: trim verbose comments and DRY up keystore tests
bucko13 May 21, 2026
2303478
feat: move signed-message fixtures to @caravan/bitcoin
bucko13 May 21, 2026
467cdd7
chore(bitcoin): drop process-language comment on SIGNED_MESSAGE_TEXT
bucko13 May 21, 2026
020b9b6
docs(bitcoin): explain SIGNED_MESSAGE_TEXT
bucko13 May 21, 2026
7967cd0
feat(wallets): verify device signatures before yielding an Entry
bucko13 May 21, 2026
e895adc
fix(wallets): canonical P2WPKH header byte (v+39) for Ledger/Jade sigs
bucko13 May 21, 2026
a9ea3a5
test(wallets): use TEST_FIXTURES in jade.test.ts and drop runtime sig…
bucko13 May 21, 2026
7a92501
refactor: address PR review feedback
bucko13 May 21, 2026
9e39445
fix(wallets): externalize bip322-js
bucko13 May 21, 2026
882152c
test(coordinator): match name() pattern
bucko13 May 21, 2026
bbea2a6
fix(wallets): drop Jade signature normalizer
bucko13 May 21, 2026
fee17f2
fix(wallets): drop BitBox from SignMessage
bucko13 May 21, 2026
16caf8b
fix(coordinator): Coldcard message-signing UX
bucko13 May 21, 2026
0a06f90
fix(coordinator): pass full SignMessageResult to message-signing matcher
bucko13 May 21, 2026
948c273
changeset: caravan-coordinator minor bump
bucko13 May 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-foxes-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@caravan/bitcoin": patch
---

Add pre-computed BIP-137 and BIP-322 signatures to each `TEST_FIXTURES.multisigs` entry under a new `signedMessages: { message, bip137, bip322 }` field. Signed at the entry's `bip32Path` with the open_source seed; consumers can verify the signatures without re-signing in-test.
5 changes: 5 additions & 0 deletions .changeset/swift-pandas-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"caravan-coordinator": minor
---

Add a message-signing category to the `/#/test` keystore test suite, exercising per-cosigner BIP-137 signing on Ledger, Trezor, Jade, and Coldcard. Includes a new Coldcard SD-card UX (download request `.txt`, upload signed `.txt`) and a `ColdcardTextReader` for the signed-message file.
5 changes: 5 additions & 0 deletions .changeset/wise-otters-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@caravan/wallets": major
---

Per-cosigner message signing across Ledger, Trezor, Jade, and Coldcard. `SignMessage` normalizes each SDK's native output into `SignMessageResult = {bip32Path, signature, pubkey}` and surfaces SDK errors as `MessageSigningError`. Each `.run()` verifies the returned signature against the cosigner `pubkey` before yielding a result. Breaking: `SignMessage` requires `pubkey` and `.run()` now returns `SignMessageResult`; the verifier, the result type, and the error taxonomy live in the new internal `@caravan/messages` package. BitBox is not supported — its firmware refuses `sign_message` at multisig-purpose cosigner paths (BIP-45/48); only BIP-49/84 are accepted.
8 changes: 8 additions & 0 deletions apps/coordinator/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ Here you should build out a query key map and the hooks that will be used to acc
See the [transactions client](./src/clients/transactions.ts) for an example of how to build out a new query.


## In-app test suite (`/#/test`)

The coordinator ships an in-app keystore test suite at the `/#/test` route (mounted in `App.tsx`). It walks a connected keystore through a sequence of real device interactions and records pass/fail per step.

Suite composition: each keystore has `src/tests/<keystore>.js` that concatenates category modules. Current categories: `publicKeys`, `extendedPublicKeys`, `signing` (PSBTs), `addresses`, `registration`, and `messageSigning`. To add a new category, write a new `src/tests/<category>.jsx` exporting a `<category>Tests(keystore)` factory and append `.concat(<category>Tests(KEYSTORE))` to each supporting `src/tests/<keystore>.js`.

`messageSigning` covers Ledger / Trezor / Jade / BitBox / Coldcard; it signs a fixed UTF-8 message with the `open_source` cosigner key from `TEST_FIXTURES.multisigs` and verifies cryptographically via `verifyMessageSignature` from `@caravan/wallets`. BCUR2, Hermit, and Custom are intentionally omitted.

## Troubleshooting

### React "Unexpected fiber pop" in Chrome
Expand Down
1 change: 1 addition & 0 deletions apps/coordinator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
"@caravan/clients": "*",
"@caravan/descriptors": "^0.2.0",
"@caravan/health": "*",
"@caravan/messages": "*",
"@caravan/psbt": "*",
"@caravan/transactions": "*",
"@caravan/typescript-config": "*",
Expand Down
12 changes: 10 additions & 2 deletions apps/coordinator/src/components/Coldcard/ColdcardFileReader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const ColdcardFileReaderBase = ({
if (hasError) return; // do not continue if the bip32path is invalid
if (singleAcceptedFile(acceptedFiles, rejectedFiles)) {
const file = acceptedFiles[0];
if (fileType === "JSON") {
if (fileType === "JSON" || fileType === "TXT") {
onReceive(await file.text());
} else {
// With PSBT files, the actual spec says it should be stored in binary.
Expand Down Expand Up @@ -103,7 +103,9 @@ const ColdcardFileReaderBase = ({
>
<UploadIcon classes={{ root: styles.uploadIcon }} />
<p className={styles.instruction}>
{fileType === "JSON" ? "Upload The XPUB" : "Upload Signed PSBT"}
{fileType === "JSON" && "Upload The XPUB"}
{fileType === "TXT" && "Upload Signed Message File"}
{fileType === "PSBT" && "Upload Signed PSBT"}
</p>
</Dropzone>
</Box>
Expand Down Expand Up @@ -163,3 +165,9 @@ export const ColdcardPSBTReader = (props) => {
/>
);
};

export const ColdcardTextReader = (props) => {
return (
<ColdcardFileReaderBase {...props} fileType="TXT" validFileFormats=".txt" />
);
};
13 changes: 11 additions & 2 deletions apps/coordinator/src/components/Coldcard/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { ColdcardJSONReader, ColdcardPSBTReader } from "./ColdcardFileReader";
import {
ColdcardJSONReader,
ColdcardPSBTReader,
ColdcardTextReader,
} from "./ColdcardFileReader";
import ColdcardSigningButtons from "./ColdcardSigningButtons";

export { ColdcardSigningButtons, ColdcardJSONReader, ColdcardPSBTReader };
export {
ColdcardSigningButtons,
ColdcardJSONReader,
ColdcardPSBTReader,
ColdcardTextReader,
};
56 changes: 45 additions & 11 deletions apps/coordinator/src/components/TestSuiteRun/TestRun.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
ColdcardJSONReader,
ColdcardPSBTReader,
ColdcardSigningButtons,
ColdcardTextReader,
} from "../Coldcard";
import "./TestRun.css";
import { downloadFile } from "../../utils";
Expand Down Expand Up @@ -87,6 +88,16 @@ class TestRunBase extends React.Component {
downloadFile(body, filename);
};

handleDownloadColdcardMessageRequestClick = () => {
const { test } = this.props;
const body = test.interaction().request();
const slug = test
.name()
.replace(/[^a-z0-9]+/gi, "-")
.toLowerCase();
downloadFile(body, `${moment().format("HHmm")}-${slug}.txt`);
};

handledDownloadWalletConfigClick = () => {
const { test } = this.props;
const nameBits = test.name().split(" ");
Expand Down Expand Up @@ -129,17 +140,40 @@ Derivation: ${test.params.derivation}
/>
<CardContent>
{test.description()}
{keystore.type === COLDCARD && !test.unsignedTransaction && (
<Box align="center">
<ColdcardJSONReader
interaction={test.interaction()}
onReceive={this.startParse}
onStart={this.start}
setError={this.reset}
isTest
/>
</Box>
)}
{keystore.type === COLDCARD &&
!test.unsignedTransaction &&
test.interaction().workflow?.[0] !== "request" && (
<Box align="center">
<ColdcardJSONReader
interaction={test.interaction()}
onReceive={this.startParse}
onStart={this.start}
setError={this.reset}
isTest
/>
</Box>
)}
{keystore.type === COLDCARD &&
!test.unsignedTransaction &&
test.interaction().workflow?.[0] === "request" && (
<Box align="center" style={{ marginTop: "2em" }}>
<Button
variant="contained"
color="primary"
onClick={this.handleDownloadColdcardMessageRequestClick}
style={{ marginBottom: "1em" }}
>
Download Request File
</Button>
<ColdcardTextReader
interaction={test.interaction()}
onReceive={this.startParse}
onStart={this.start}
setError={this.reset}
isTest
/>
</Box>
)}
{keystore.type === COLDCARD && test.unsignedTransaction && (
<Box align="center" style={{ marginTop: "2em" }}>
<ColdcardSigningButtons
Expand Down
2 changes: 2 additions & 0 deletions apps/coordinator/src/tests/bitbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { signingTests } from "./signing";
import addressTests from "./addresses";
import registrationTests from "./registration";

// BitBox firmware does not support message signing at caravan's
// multisig-purpose cosigner paths; messageSigningTests is omitted.
export default publicKeyTests(BITBOX)
.concat(extendedPublicKeyTests(BITBOX))
.concat(signingTests(BITBOX))
Expand Down
4 changes: 3 additions & 1 deletion apps/coordinator/src/tests/coldcard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { COLDCARD } from "@caravan/wallets";
import extendedPublicKeyTests from "./extendedPublicKeys";
import { signingTests } from "./signing";
import publicKeyTests from "./publicKeys";
import { messageSigningTests } from "./messageSigning";

export default publicKeyTests(COLDCARD)
.concat(extendedPublicKeyTests(COLDCARD))
.concat(signingTests(COLDCARD));
.concat(signingTests(COLDCARD))
.concat(messageSigningTests(COLDCARD));
4 changes: 3 additions & 1 deletion apps/coordinator/src/tests/jade.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import extendedPublicKeyTests from "./extendedPublicKeys";
import { signingTests } from "./signing";
import addressTests from "./addresses";
import registrationTests from "./registration";
import { messageSigningTests } from "./messageSigning";

export default publicKeyTests(JADE)
.concat(extendedPublicKeyTests(JADE))
.concat(signingTests(JADE))
.concat(addressTests(JADE))
.concat(registrationTests(JADE));
.concat(registrationTests(JADE))
.concat(messageSigningTests(JADE));
4 changes: 3 additions & 1 deletion apps/coordinator/src/tests/ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import extendedPublicKeyTests from "./extendedPublicKeys";
import { signingTests } from "./signing";
import addressTests from "./addresses";
import registrationTests from "./registration";
import { messageSigningTests } from "./messageSigning";

export default publicKeyTests(LEDGER)
.concat(extendedPublicKeyTests(LEDGER))
.concat(signingTests(LEDGER))
.concat(addressTests(LEDGER))
.concat(registrationTests(LEDGER));
.concat(registrationTests(LEDGER))
.concat(messageSigningTests(LEDGER));
143 changes: 143 additions & 0 deletions apps/coordinator/src/tests/messageSigning.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React from "react";
import { TEST_FIXTURES, deriveChildPublicKey } from "@caravan/bitcoin";
import { verifyMessageSignature } from "@caravan/messages";
import { SignMessage } from "@caravan/wallets";
import { Box, Table, TableBody, TableRow, TableCell } from "@mui/material";

import Test from "./Test";

const DEFAULT_MESSAGE = "caravan message-signing smoke test";

class MessageSigningTest extends Test {
name() {
return `Sign ${this.params.network} ${this.params.type} message`;
}

description() {
return (
<Box>
<p>
Sign the message below with the <strong>open_source</strong>{" "}
cosigner&apos;s key, then verify the signature against the expected
pubkey.
</p>
<Table>
<TableBody>
<TableRow>
<TableCell>Address type:</TableCell>
<TableCell>
<code>
{this.params.type} ({this.params.network})
</code>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Message:</TableCell>
<TableCell>
<code>{this.params.message}</code>
</TableCell>
</TableRow>
<TableRow>
<TableCell>BIP-32 path:</TableCell>
<TableCell>
<code>{this.params.bip32Path}</code>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Expected pubkey:</TableCell>
<TableCell>
<code>{this.params.pubkey}</code>
</TableCell>
</TableRow>
<TableRow>
<TableCell>Protocol:</TableCell>
<TableCell>BIP-137 (loose-mode verification)</TableCell>
</TableRow>
</TableBody>
</Table>
</Box>
);
}

interaction() {
return SignMessage({
keystore: this.params.keystore,
network: this.params.network,
bip32Path: this.params.bip32Path,
message: this.params.message,
pubkey: this.params.pubkey,
});
}

// Verification is cryptographic, not byte-equality: pass the returned
// SignMessageResult's (signature, pubkey, message) to the verifier and trust its
// boolean. The expected-value passed into matches() is ignored.
// eslint-disable-next-line class-methods-use-this
matches(_expected, entry) {
return verifyMessageSignature({
message: this.params.message,
signature: entry.signature,
pubkey: entry.pubkey,
});
}

// The base Test.runParse() unpacks Coldcard parse() output assuming
// either an xpub-import shape ({rootFingerprint, ...}) or a signed-PSBT
// shape ({pubkey: [sig, ...]}) — for the latter it sends Object.values()[0]
// to the resolver. The SignMessageResult shape ({bip32Path, signature,
// pubkey}) needs to reach matches() intact for cryptographic verification.
async runParse(data) {
try {
const entry = await this.actual(data);
return this.resolve(entry);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return { status: Test.ERROR, message: e.message };
}
}

expected() {
// matches() ignores the expected value (it verifies the actual SignMessageResult
// cryptographically). Returned here so the UI has something to render
// alongside the actual response.
return this.params.pubkey;
}
}

export function messageSigningTests(keystore) {
return TEST_FIXTURES.multisigs.map((fixture) => {
// braidDetails.extendedPublicKeys[0] is the open_source cosigner xpub
// at the branch level (e.g. m/45'/1'/100'). Derive the open_source
// pubkey at the address-level path (e.g. m/45'/1'/100'/0/0) by
// stripping the branch prefix off fixture.bip32Path.
const openSourceNode = fixture.braidDetails.extendedPublicKeys[0];
const branchPath = openSourceNode.path;
// Defensive: catch fixture drift at suite-construction time rather
// than at on-device verification time. If the address-level path
// doesn't sit under the branch path, the slice below would produce
// a bogus relativePath and silently derive the wrong pubkey.
if (!fixture.bip32Path.startsWith(`${branchPath}/`)) {
throw new Error(
`messageSigning fixture mismatch: bip32Path "${fixture.bip32Path}" is not under branchPath "${branchPath}"`,
);
}
const relativePath = fixture.bip32Path.slice(branchPath.length + 1);
const pubkey = deriveChildPublicKey(
openSourceNode.base58String,
relativePath,
fixture.network,
);

return new MessageSigningTest({
keystore,
network: fixture.network,
type: fixture.type,
bip32Path: fixture.bip32Path,
message: DEFAULT_MESSAGE,
pubkey,
});
});
}

export default messageSigningTests;
4 changes: 3 additions & 1 deletion apps/coordinator/src/tests/trezor.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import publicKeyTests from "./publicKeys";
import extendedPublicKeyTests from "./extendedPublicKeys";
import addressTests from "./addresses";
import { signingTests } from "./signing";
import { messageSigningTests } from "./messageSigning";

export default publicKeyTests(TREZOR)
.concat(extendedPublicKeyTests(TREZOR))
.concat(signingTests(TREZOR))
.concat(addressTests(TREZOR));
.concat(addressTests(TREZOR))
.concat(messageSigningTests(TREZOR));
Loading
Loading