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
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
-
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 SignDigestError wrapping CkTapError plus an InvalidDigestLength variant for the digest.len() != 32 guard 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 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.
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 notpuband the FFI crate (cktap-ffi/src/tap_signer.rs) only re-exportssign_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-protoexposes bothcard.sign_digest(...)(proto.py) and a higher-levelsign_message(...)wrapper (cli.py) that:\x18Bitcoin Signed Message:\n+ varint(len) + msg).signcommand withsubpath/fullpath.This is the canonical workflow for:
Today none of this is reachable from the Swift/Kotlin bindings —
sign_psbtonly 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:
…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: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
sign_message→sign_digest:cktap/cli.py(search forsign_message)sign_digest:cktap/proto.py(search fordef sign_digest)signimpl in this repo:lib/src/tap_signer.rs(async fn sign)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-
signroute or a separatesign_digestwrapper, and whether the FFI return type should be raw signature bytes or a struct with pubkey included.