bitcoindevkit / bitcoindevkit/rust-cktap

Expose digest signing via FFI to enable Bitcoin Signed Message support

Open
#70 2 comments 1 reaction 1 assignee Claimed by @r1b2ns View on GitHub
enhancement
Dominant language
Rust
Stars
25
Forks
11
Avg merge
7h 40m
Merged PRs (30d)
3

Description

## Summary

The Rust library can sign an arbitrary 32-byte digest with the Tapsigner (see [`lib/src/tap_signer.rs`](https://github.com/bitcoindevkit/rust-cktap/blob/master/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`](https://github.com/bitcoindevkit/rust-cktap/blob/master/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`](https://github.com/coinkite/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:

```rust
// lib/src/tap_signer.rs
pub async fn sign(
&mut self,
digest: [u8; 32],
sub_path: Vec,
cvc: &str,
) -> Result { ... }
```

…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`:

```rust
pub async fn sign_digest(
&self,
digest: Vec, // must be 32 bytes
sub_path: Vec,
cvc: String,
) -> Result
```

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_message` → `sign_digest`: [`cktap/cli.py`](https://github.com/coinkite/coinkite-tap-proto/blob/master/cktap/cli.py) (search for `sign_message`)
- Coinkite Python proto `sign_digest`: [`cktap/proto.py`](https://github.com/coinkite/coinkite-tap-proto/blob/master/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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.