Skip to content

Expose digest signing via FFI to enable Bitcoin Signed Message support #70

Description

@r1b2ns

Summary

The Rust library can sign an arbitrary 32-byte digest with the Tapsigner (see lib/src/tap_signer.rsasync 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:

  1. Builds the BIP-137 "Bitcoin Signed Message" preamble (\x18Bitcoin Signed Message:\n + varint(len) + msg).
  2. Double-SHA256s it to get a 32-byte digest.
  3. Calls the card's sign command with subpath/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

  1. 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.

  2. 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.

  3. Define SignDigestError wrapping CkTapError plus an InvalidDigestLength variant for the digest.len() != 32 guard at the FFI boundary.

  4. Regenerate the UniFFI bindings so Swift/Kotlin consumers get signDigest(digest:subPath:cvc:) automatically.

References

  • Coinkite Python CLI sign_messagesign_digest: cktap/cli.py (search for sign_message)
  • Coinkite Python proto sign_digest: cktap/proto.py (search for def sign_digest)
  • Existing private sign impl in this repo: lib/src/tap_signer.rs (async fn sign)
  • FFI surface today: cktap-ffi/src/tap_signer.rs (only sign_psbt is pub)

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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions