Skip to content

feat(security): harden Inv #8 BIP-137 message-sign with byte-fingerprint + drainer-string refusal (#454) - #524

Merged
szhygulin merged 1 commit into
mainfrom
feat/454-drainer-refusal-and-hash
Apr 28, 2026
Merged

feat(security): harden Inv #8 BIP-137 message-sign with byte-fingerprint + drainer-string refusal (#454)#524
szhygulin merged 1 commit into
mainfrom
feat/454-drainer-refusal-and-hash

Conversation

@szhygulin

Copy link
Copy Markdown
Collaborator

Summary

Two-part hardening for the BIP-137 message-sign surface that adversarial smoke-test script a110 confirmed terminates at the user's eyes on the Ledger Nano OLED — vulnerable to skim, line-1-only, trust-the-agent, and unicode-confusable substitution failure modes.

Closes #454.

(a) Byte-fingerprint preview

SignedBitcoinMessage and SignedLitecoinMessage now carry messageSha256 — lowercase hex SHA-256 of the exact UTF-8 bytes submitted to the device for signing. The agent surfaces this in the Inv #8 verbatim block so a user concerned about confusable-char substitution can recompute on a separate device:

printf '%s' '<message>' | sha256sum

(c) Drainer-string refusal

New src/security/drainer-pattern.ts — pure function that scans the message for value-transfer / authorization markers and refuses BEFORE any device interaction. Fires regardless of agent cooperation (a compromised agent can't suppress this by skipping the preview).

Tier Pattern shape Match
Markers single-word transfer, authorize, grant, custody, release, consent
Templates multi-word (precedence) I authorize, granting full custody, I consent to, I hereby transfer, release my

Match is case-insensitive substring. Templates take precedence over markers so the refusal error names the most-specific phrase that tripped — more actionable diagnostic than a single-word match.

False-positive control

A test asserts realistic SIWB-shaped messages (example.com wants you to sign in with your Bitcoin account... Verify ownership for proof-of-funds... Nonce: 12345) pass through cleanly. The asymmetric cost (false positive ≈ inconvenience; false negative ≈ fund loss) justifies erring on the side of refusal.

Scope

sign_message_btc and sign_message_ltc ONLY, per the issue. Contacts-CRUD signing is structurally fixed (VaultPilot-contact-v1: JSON preimage on a controlled shape) and goes through a different path that never hits this check.

Out of scope (per issue)

  • Coordinate with Ledger for a message-hash preview line in the BTC app — firmware-vendor work, not this repo.
  • Heuristic refusal for "embedded address NOT in user's saved contacts" — the issue itself flags this as a stretch and it requires plumbing the contacts blob into the message-sign path.

What changes

  • src/security/drainer-pattern.ts (new) — pure detector + assertion helper.
  • src/modules/btc/actions.ts — calls assertNoDrainerPattern() before pairing lookup; computes + returns messageSha256.
  • src/modules/litecoin/actions.ts — same shape, mirror change.
  • src/index.ts — both tool descriptions updated to advertise the new defenses + new response field.
  • test/drainer-pattern.test.ts (new) — 17 unit tests for the detector + assertion (markers / templates / case-insensitivity / template precedence / error message shape).
  • test/btc-pr4-portfolio-message-sign.test.ts — 5 new tests covering: SHA-256 of ASCII message, SHA-256 of multibyte UTF-8 (café 你好 —), marker-match refusal, template-match refusal, refusal-fires-before-pairing, SIWB false-positive control.

Test plan

  • npm run build clean
  • npx tsc --noEmit clean
  • npx vitest run — all 2441 tests pass locally (22 new + 2419 prior)
  • CI green
  • Manual: sign_message_btc({wallet, message: \"Sign in to example.com\"}) → response includes messageSha256 and printf '%s' 'Sign in to example.com' | sha256sum matches it
  • Manual: sign_message_btc({wallet, message: \"I authorize transfer of 1 BTC\"}) → throws MESSAGE-SIGN REFUSED — drainer-pattern template \"i authorize\"...

🤖 Generated with Claude Code

…int + drainer-string refusal (#454)

Two-part hardening for the BIP-137 message-sign surface that
adversarial smoke-test script a110 confirmed terminates at the
user's eyes on the Ledger Nano OLED — vulnerable to skim,
line-1-only, trust-the-agent, and unicode-confusable substitution
failure modes.

## (a) Byte-fingerprint preview

`SignedBitcoinMessage` and `SignedLitecoinMessage` now carry
`messageSha256` — lowercase hex SHA-256 of the exact UTF-8 bytes
submitted to the device for signing. The agent surfaces this in the
Inv #8 verbatim block so a user concerned about confusable-char
substitution can recompute on a separate device:

  printf '%s' '<message>' | sha256sum

## (c) Drainer-string refusal

New `src/security/drainer-pattern.ts` — pure function that scans the
message for value-transfer / authorization markers and refuses
BEFORE any device interaction. Fires regardless of agent
cooperation (a compromised agent can't suppress this by skipping the
preview).

Two-tier match (case-insensitive substring):

- Single-word semantic markers — `transfer` / `authorize` / `grant` /
  `custody` / `release` / `consent`
- Multi-word drainer templates (more specific, take precedence) —
  "I authorize" / "granting full custody" / "I consent to" / "I
  hereby transfer" / "release my"

Refusal error names the matched pattern + kind so the user sees
exactly why it tripped and gets a concrete next step (reword the
message or use a non-vaultpilot signing tool).

## False positives are recoverable

Legitimate Sign-In-with-Bitcoin / proof-of-funds / proof-of-ownership
messages don't typically use these markers — they read "Verify
ownership of <addr>" or "Sign in to <site>". A control test asserts
a realistic SIWB-shaped message passes through cleanly. If a user
genuinely needs to sign a transfer-shaped message, they reword or use
a different signing tool — the asymmetric cost (false positive ≈
inconvenience; false negative ≈ fund loss) justifies erring on the
side of refusal.

## Scope

`sign_message_btc` and `sign_message_ltc` ONLY. Contacts-CRUD signing
is structurally fixed (`VaultPilot-contact-v1:` JSON preimage on a
controlled shape) and goes through a different path that never hits
this check.

## Out of scope (per issue)

- Coordinate with Ledger for a message-hash preview line in the BTC
  app — firmware-vendor work, not this repo.
- Heuristic refusal for "embedded address NOT in user's saved
  contacts" — the issue itself flags this as a stretch and it
  requires plumbing the contacts blob into the message-sign path.

Closes #454.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@szhygulin
szhygulin merged commit 87f1328 into main Apr 28, 2026
4 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 28, 2026
@szhygulin
szhygulin deleted the feat/454-drainer-refusal-and-hash branch May 19, 2026 04:26
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security] Harden Inv #8 BIP-137 message-sign with byte-fingerprint + drainer-string refusal

1 participant