feat(ffi): add hush-ffi C ABI crate + C# SDK + Go SDK#83
feat(ffi): add hush-ffi C ABI crate + C# SDK + Go SDK#83
Conversation
Introduce a pluggable CryptoBackend interface in the TS SDK so all cryptographic operations (hashing, signing, verification) can run through either the existing @noble/* pure-JS libraries or hush-core compiled to WebAssembly. The noble backend remains the default; consumers opt into WASM via `initWasm()` at startup. Rust (hush-wasm): - Add generate_keypair, sign_ed25519, public_key_from_private exports - Add hash_sha256_bytes, hash_keccak256_bytes returning Uint8Array - Update TypeScript type declarations - Add unit + wasm integration tests TypeScript (hush-ts): - New backend.ts with CryptoBackend interface, getBackend/setBackend/initWasm - New noble-backend.ts wrapping @noble/* into CryptoBackend shape - New wasm-backend.ts wrapping @clawdstrike/wasm (dynamic import only) - Rewire hash.ts, sign.ts, watermarking.ts to use getBackend() - Zero @noble/* imports outside noble-backend.ts - Add @clawdstrike/wasm as optional peerDependency - Add backend + determinism test suites Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The WASM verify_ed25519 binding throws on malformed key/signature input, while the noble backend normalizes errors to false. Wrap the call in try/catch so both backends have identical error semantics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The web target of @clawdstrike/wasm (built with wasm-pack --target web) requires calling the default export init() to instantiate the WASM module before any other exports are usable. Without this, initWasm() could return true while crypto calls fail at runtime. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce a C ABI surface (hush-ffi) exposing 31 extern "C" functions for hashing, Ed25519 signing/verification, receipt lifecycle, Merkle trees, jailbreak detection, output sanitization, and prompt watermarking. cbindgen generates a portable hush.h header. On top of that, add idiomatic C# (Backbay.Clawdstrike, netstandard2.1) and Go (github.com/backbay-labs/clawdstrike/packages/sdk/hush-go) SDKs that consume the C ABI via P/Invoke and cgo respectively. - 49 Rust FFI tests passing, clippy-clean - C# uses SafeHandle for keypair lifecycle, NativeString for callee strings - Go uses runtime.SetFinalizer + explicit Close() for keypair zeroization - All CGo declarations isolated in single cgo.go file Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ad912ff371
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // 0 = valid, 1 = invalid, negative = error | ||
| if (rc < 0) | ||
| throw HushException.FromLastError(); | ||
| return rc == 0; |
There was a problem hiding this comment.
Interpret hush_verify return codes correctly
The FFI contract for verification APIs is 1 = valid, 0 = invalid, -1 = error (documented in crates/libs/hush-ffi/hush.h), but this method returns true on rc == 0. That inverts verification semantics so tampered signatures are reported as valid and valid signatures are reported as invalid; the same mapping is used in VerifyBytes and Hush.Merkle.Merkle.VerifyProof, so C# callers cannot trust these verification results.
Useful? React with 👍 / 👎.
packages/sdk/hush-go/crypto.go
Outdated
| case 0: | ||
| return true, nil | ||
| case 1: | ||
| return false, nil |
There was a problem hiding this comment.
Fix inverted verification status mapping in Go SDK
These branches treat rc == 0 as a successful verification, but the C ABI returns 1 for valid and 0 for invalid (crates/libs/hush-ffi/hush.h). As written, VerifyEd25519/VerifyEd25519Bytes (and VerifyMerkleProof in merkle.go) invert trust decisions, so invalid signatures/proofs can be accepted while valid ones are rejected.
Useful? React with 👍 / 👎.
- Make cbindgen a feature-gated build dependency so the offline vendored CI build doesn't need to vendor it (hush.h is checked in). - Replace intra-doc links to `hush_last_error` and `hush_free_string` with plain code spans — they live in sibling modules and can't be resolved by rustdoc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cbindgen was pinned in Cargo.lock and required by the vendored offline build even as an optional dep. Since hush.h is checked into the repo, remove the build-dep entirely and document the manual regeneration command in Cargo.toml. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
FFI contract is 1=valid, 0=invalid, -1=error. Both SDKs had it backwards (treating 0 as valid). Also add runtime.KeepAlive to Go Keypair methods to prevent use-after-free from GC finalizer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98d2a54969
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| func lastError() error { | ||
| msg := goStringFromC(ffiLastError()) | ||
| if msg == "" { |
There was a problem hiding this comment.
Keep FFI error retrieval on the same OS thread
lastError() performs a second cgo call to hush_last_error, but the Rust side stores errors in thread-local storage (hush_last_error is valid only on the same thread as the failing call). Go goroutines can resume on a different OS thread between cgo calls, so failures can nondeterministically surface as "hush-ffi: unknown error" instead of the real native error, which makes production debugging unreliable. Consider pinning the goroutine (or returning error text from the failing FFI call path) so error retrieval is thread-stable.
Useful? React with 👍 / 👎.
| if !guard.contains_key(&key) { | ||
| let wm = clawdstrike::PromptWatermarker::new(cfg).map_err(|e| format!("{e:?}"))?; | ||
| guard.insert(key.clone(), Arc::new(wm)); | ||
| } |
There was a problem hiding this comment.
Bound cached watermarker instances by configuration
get_or_create_watermarker inserts a new PromptWatermarker for each unseen canonicalized config and never evicts entries. In long-lived processes where config_json can vary per request (for example via dynamic metadata fields), this global cache grows without bound and retains keypairs indefinitely, causing avoidable memory growth over time.
Useful? React with 👍 / 👎.
| #[unsafe(no_mangle)] | ||
| pub unsafe extern "C" fn hush_last_error() -> *const c_char { | ||
| LAST_ERROR.with(|cell| cell.borrow().as_ptr()) | ||
| } |
There was a problem hiding this comment.
Dangling pointer from hush_last_error due to RefCell borrow
Low Severity
hush_last_error returns a raw pointer obtained inside a LAST_ERROR.with closure via cell.borrow().as_ptr(). The Ref guard from borrow() is dropped when the closure returns, making the RefCell available for mutable borrowing again. Although the CString data remains valid until set_last_error replaces it, this pattern is fragile — if any code path between the caller receiving the pointer and reading it triggers set_last_error on the same thread (including from a panic hook or signal handler), the pointer becomes dangling. A safer approach would store the CString in a way that doesn't require borrowing to obtain the pointer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f471eca00e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // lastError retrieves the last error message from the native library. | ||
| // Returns a generic error if no message is available. | ||
| func lastError() error { | ||
| msg := goStringFromC(ffiLastError()) |
There was a problem hiding this comment.
Keep goroutine on one OS thread when fetching FFI errors
The Go binding reads hush_last_error() in a separate cgo call from the failing native call, but the Rust FFI explicitly documents that the error string is only valid on the same thread (crates/libs/hush-ffi/src/lib.rs, error handling section). In Go, goroutines can resume on a different OS thread between cgo calls unless runtime.LockOSThread is used, so under scheduler contention this can surface empty or unrelated error messages instead of the real native failure reason.
Useful? React with 👍 / 👎.
| cr := allocCString(receiptJSON) | ||
| defer freeCString(cr) | ||
|
|
||
| p := ffiSignReceipt(cr, kp.ptr) |
There was a problem hiding this comment.
Reject nil keypairs before calling hush_sign_receipt
SignReceipt dereferences kp.ptr without checking whether kp is nil, so callers can trigger a Go panic (nil pointer dereference) instead of receiving a regular SDK error. This is easy to hit when keypair creation fails and a nil pointer is propagated, and it turns a recoverable input/flow issue into a process crash.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
| ) -> Result<Arc<clawdstrike::PromptWatermarker>, String> { | ||
| let key = watermark_key(config_json)?; | ||
| let cfg: clawdstrike::WatermarkConfig = | ||
| serde_json::from_str(config_json).map_err(|e| format!("invalid WatermarkConfig: {e}"))?; |
There was a problem hiding this comment.
Watermarker config parsed redundantly on every call
Low Severity
get_or_create_watermarker deserializes config_json into WatermarkConfig on line 28-29 unconditionally, even though cfg is only consumed inside the if !guard.contains_key(&key) cache-miss branch (line 41). watermark_key() already performs the same deserialization internally. On the common cache-hit path, this second parse is entirely wasted work. Moving the cfg parsing inside the cache-miss block avoids redundant deserialization on every call.
Additional Locations (1)
| return "", lastError() | ||
| } | ||
| return goStringFromCFree(p), nil | ||
| } |
There was a problem hiding this comment.
Go SDK MerkleProof silently wraps negative index
Low Severity
MerkleProof accepts a signed int for index, but ffiMerkleProof casts it directly to C.size_t (unsigned). A negative index silently wraps to an enormous unsigned value, producing a confusing out-of-bounds error from the Rust side. The C# SDK's GenerateProof explicitly validates index < 0 with a clear ArgumentOutOfRangeException, but the Go SDK lacks equivalent input validation.


Summary
hush-fficrate (crates/libs/hush-ffi/): C ABI exposing 31extern "C"functions for crypto, receipts, Merkle trees, jailbreak detection, output sanitization, and watermarking.cbindgengenerates a portablehush.hheader. 49 Rust tests, clippy-clean.packages/sdk/hush-csharp/): Idiomatic .NET wrapper (netstandard2.1) using P/Invoke,SafeHandlefor keypair lifecycle,NativeStringfor callee-allocated strings. xunit test suite.packages/sdk/hush-go/): Idiomatic Go wrapper via cgo withruntime.SetFinalizer+ explicitClose()for keypair zeroization. All CGo isolated in singlecgo.go. Comprehensivehush_test.go.API surface (31 functions)
hush_version,hush_last_error,hush_free_string,hush_free_byteshush_sha256,hush_sha256_hex,hush_keccak256,hush_keccak256_hex,hush_canonicalize_jsonhush_keypair_generate,from_seed,from_hex,public_key_hex,public_key_bytes,sign_hex,sign,to_hex,destroyhush_verify_ed25519,hush_verify_ed25519_byteshush_verify_receipt,hush_sign_receipt,hush_hash_receipt,hush_receipt_canonical_jsonhush_merkle_root,hush_merkle_proof,hush_verify_merkle_proofhush_detect_jailbreak,hush_sanitize_outputhush_watermark_public_key,hush_watermark_prompt,hush_extract_watermarkTest plan
cargo clippy -p hush-ffi -- -D warningspassescargo test -p hush-ffi— 49 tests passcargo build -p hush-ffiproduceslibhush_ffi.dylib+libhush_ffi.a+hush.hdotnet testinpackages/sdk/hush-csharp/(requireslibhush_ffion library path)go test ./...inpackages/sdk/hush-go/(requireslibhush_ffion library path)🤖 Generated with Claude Code
Note
High Risk
Introduces a new native/FFI surface and multiple language bindings, increasing risk around memory ownership, ABI compatibility, and cross-language error handling despite extensive tests.
Overview
Adds a new Rust
hush-ffilibrary crate exposing a C ABI (and checked-inhush.h) for hashing, Ed25519 key management/signing/verification, receipt signing/verification/hashing, Merkle root/proof operations, jailbreak detection, output sanitization, and prompt watermarking, with thread-localhush_last_error()error reporting and panic guards.Builds new consumer SDKs on top of that ABI: a .NET (
packages/sdk/hush-csharp) P/Invoke wrapper with safe handle/string lifetimes and a Go (packages/sdk/hush-go) cgo wrapper that centralizes allimport "C"usage and manages keypair lifetimes. Updateshush-wasmto add byte-returning hash APIs plus keypair/signing helpers, and updates the TS SDK to support a pluggable crypto backend with optional WASM acceleration via@clawdstrike/wasm.Written by Cursor Bugbot for commit 32d664d. This will update automatically on new commits. Configure here.