Skip to content

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

@r1b2ns

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
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions