Repository navigation
Expose digest signing via FFI to enable Bitcoin Signed Message support #70
Description
Summary
The Rust library can sign an arbitrary 32-byte digest with the Tapsigner (see lib/src/tap_signer.rs — async fn sign(digest, sub_path, cvc)), but that method is not pub and the FFI crate (cktap-ffi/src/tap_signer.rs) only re-exports sign_psbt. Consumers using the UniFFI Swift/Kotlin bindings therefore have no way to sign a non-PSBT payload.
Motivation
The official Coinkite reference implementation coinkite-tap-proto exposes both card.sign_digest(...) (proto.py) and a higher-level sign_message(...) wrapper (cli.py) that:
- Builds the BIP-137 "Bitcoin Signed Message" preamble (
\x18Bitcoin Signed Message:\n+ varint(len) + msg). - Double-SHA256s it to get a 32-byte digest.
- Calls the card's
signcommand withsubpath/fullpath.
This is the canonical workflow for:
- BIP-137 / "sign message" — proving control of an address with arbitrary text.
- Proof-of-key challenges — apps that want to verify "this card still holds the key" by signing a server-issued nonce and verifying locally against the cached pubkey.
- Generic digest signing for non-Bitcoin use cases (Lightning notes, attestations, etc.).
Today none of this is reachable from the Swift/Kotlin bindings — sign_psbt only accepts a fully-formed PSBT and signs against UTXO sighashes, so it can't be coerced into signing plain text.
Proposed change
-
Expose the raw digest signer. Either make the existing method public:
// lib/src/tap_signer.rs pub async fn sign( &mut self, digest: [u8; 32], sub_path: Vec<u32>, cvc: &str, ) -> Result<SignResponse, CkTapError> { ... }
…or add a thin wrapper
pub async fn sign_digest(...)if the existing signature should stay internal. -
Add an FFI entry point in
cktap-ffi/src/tap_signer.rs:pub async fn sign_digest( &self, digest: Vec<u8>, // must be 32 bytes sub_path: Vec<u32>, cvc: String, ) -> Result<SignedDigest, SignDigestError>
Returning a small struct that carries at least the recoverable signature and the pubkey actually used is more useful than raw bytes — consumers can verify locally without an extra round-trip.
-
Define
SignDigestErrorwrappingCkTapErrorplus anInvalidDigestLengthvariant for thedigest.len() != 32guard at the FFI boundary. -
Regenerate the UniFFI bindings so Swift/Kotlin consumers get
signDigest(digest:subPath:cvc:)automatically.
References
- Coinkite Python CLI
sign_message→sign_digest:cktap/cli.py(search forsign_message) - Coinkite Python proto
sign_digest:cktap/proto.py(search fordef sign_digest) - Existing private
signimpl in this repo:lib/src/tap_signer.rs(async fn sign) - FFI surface today:
cktap-ffi/src/tap_signer.rs(onlysign_psbtispub)
Happy to put up a PR if the approach is agreeable — would just like to confirm whether you'd prefer the public-sign route or a separate sign_digest wrapper, and whether the FFI return type should be raw signature bytes or a struct with pubkey included.
Activity
notmandatory commentedon May 19, 2026
Yes this is a good idea, thanks for the detailed explanation, feel free to create a PR for it.
r1b2ns commentedon May 20, 2026
I already have the code and I’ll prepare it this week
feat(ffi): expose sign_digest for arbitrary 32-byte digest signing
feat(ffi): expose sign_digest for arbitrary 32-byte digest signing
feat(ffi): expose sign_digest for arbitrary 32-byte digest signing