Skip to content

feat(ffi): add hush-ffi C ABI crate + C# SDK + Go SDK#83

Open
bb-connor wants to merge 9 commits intomainfrom
feat/hush-ffi-csharp-go
Open

feat(ffi): add hush-ffi C ABI crate + C# SDK + Go SDK#83
bb-connor wants to merge 9 commits intomainfrom
feat/hush-ffi-csharp-go

Conversation

@bb-connor
Copy link
Collaborator

@bb-connor bb-connor commented Feb 14, 2026

Summary

  • hush-ffi crate (crates/libs/hush-ffi/): C ABI exposing 31 extern "C" functions for crypto, receipts, Merkle trees, jailbreak detection, output sanitization, and watermarking. cbindgen generates a portable hush.h header. 49 Rust tests, clippy-clean.
  • C# SDK (packages/sdk/hush-csharp/): Idiomatic .NET wrapper (netstandard2.1) using P/Invoke, SafeHandle for keypair lifecycle, NativeString for callee-allocated strings. xunit test suite.
  • Go SDK (packages/sdk/hush-go/): Idiomatic Go wrapper via cgo with runtime.SetFinalizer + explicit Close() for keypair zeroization. All CGo isolated in single cgo.go. Comprehensive hush_test.go.

API surface (31 functions)

Area Functions
Infra hush_version, hush_last_error, hush_free_string, hush_free_bytes
Hashing hush_sha256, hush_sha256_hex, hush_keccak256, hush_keccak256_hex, hush_canonicalize_json
Keypair hush_keypair_generate, from_seed, from_hex, public_key_hex, public_key_bytes, sign_hex, sign, to_hex, destroy
Verify hush_verify_ed25519, hush_verify_ed25519_bytes
Receipt hush_verify_receipt, hush_sign_receipt, hush_hash_receipt, hush_receipt_canonical_json
Merkle hush_merkle_root, hush_merkle_proof, hush_verify_merkle_proof
Security hush_detect_jailbreak, hush_sanitize_output
Watermark hush_watermark_public_key, hush_watermark_prompt, hush_extract_watermark

Test plan

  • cargo clippy -p hush-ffi -- -D warnings passes
  • cargo test -p hush-ffi — 49 tests pass
  • cargo build -p hush-ffi produces libhush_ffi.dylib + libhush_ffi.a + hush.h
  • dotnet test in packages/sdk/hush-csharp/ (requires libhush_ffi on library path)
  • go test ./... in packages/sdk/hush-go/ (requires libhush_ffi on 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-ffi library crate exposing a C ABI (and checked-in hush.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-local hush_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 all import "C" usage and manages keypair lifetimes. Updates hush-wasm to 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.

bb-connor and others added 4 commits February 14, 2026 00:49
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>
@bb-connor
Copy link
Collaborator Author

@codex

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines 165 to 168
case 0:
return true, nil
case 1:
return false, nil

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

bb-connor and others added 3 commits February 14, 2026 01:28
- 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>
@bb-connor
Copy link
Collaborator Author

@codex

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +14 to +16
func lastError() error {
msg := goStringFromC(ffiLastError())
if msg == "" {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines 32 to 35
if !guard.contains_key(&key) {
let wm = clawdstrike::PromptWatermarker::new(cfg).map_err(|e| format!("{e:?}"))?;
guard.insert(key.clone(), Arc::new(wm));
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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())
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@bb-connor
Copy link
Collaborator Author

@codex

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}"))?;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Fix in Cursor Fix in Web

return "", lastError()
}
return goStringFromCFree(p), nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant