feat(go): remote signer backends to thirteen-backend parity (stacked on #164) - #179
feat(go): remote signer backends to thirteen-backend parity (stacked on #164)#179amilz wants to merge 39 commits into
Conversation
Adds a third-language implementation alongside Rust and TypeScript with full parity to the shared SolanaSigner contract. Foundation phase only. - core: Signer interface, SignedTransaction/Completeness, redacting SignerError (+ SanitizeRemoteResponse), txutil (bincode+base64 serialize / add-signature / classify), VerifyEd25519, HTTPS-only HTTP client, concurrent batch helpers - signers/memory: in-memory Ed25519 reference backend (base58 / u8-array / raw bytes / Solana CLI keypair file) - testutils: deterministic keypair + test-transaction helpers - dev tooling: justfile go-* recipes wired into fmt/build/test/test-integration, .golangci.yml, .gitignore, .pre-commit-config.yaml Single Go module at go/ (github.com/solana-foundation/solana-keychain/go), backends grouped under signers/. Built on gagliardetto/solana-go; no v2/v3 SDK adapter. Transaction serialization verified byte-identical to Rust bincode via a pinned cross-language golden vector (core/parity_test.go).
- validate the embedded public half of 64-byte keypair bytes (parity with Rust Keypair::try_from, which rejects inconsistent keypair bytes) - type memory.Config.PrivateKey as []byte: the 32-byte seed form is not a valid ed25519.PrivateKey value - honor HTTP(S)_PROXY environment configuration in core.NewHTTPClient (parity with reqwest's default system-proxy behavior) - relax the go directive from a pinned patch (1.25.6) to 1.25.0 so consumers are not forced onto a specific patch toolchain Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
…crypto) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/vault: transit sign endpoint, vault:vN: prefix stripping, local signature verification, key-metadata health check, and the wiremock test suite (httptest). Includes the -tags=integration test mirroring test_vault_integration.rs, wired to 'just go-test-integration'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/turnkey: P-256 API-key request stamping (X-Stamp), the r/s left-padding quirk with its dedicated unit test, whoami availability check, and the full wiremock test suite (httptest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/privy: basic auth + privy-app-id headers, wallet lookup at construction (Rust init() parity), signMessage RPC with base64 encoding, local signature verification, and the wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/gcp_kms: PureEdDSA via AsymmetricSign with raw data (EC_SIGN_ED25519, never Digest), GetPublicKey algorithm health check, client-injection seam for tests, and the full unit test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/para: X-API-Key auth, sk_ prefix + UUID + HTTPS config validation, hex sign-raw flow with 0x-prefix handling, status-gated availability with the 5s bound, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/openfort: x-wallet-auth ES256 JWT (uris/reqHash claims, canonical key-sorted JSON hash), dual-format wallet secret (PEM or bare base64 DER) parsed lazily at sign time, 0x-hex signature flow with local verification, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/aws_kms: RAW MessageType + ED25519_SHA_512 signing, config-supplied base58 pubkey with local signature verification, DescribeKey availability gate (ECC_NIST_EDWARDS25519, enabled, SIGN_VERIFY), SDK client-injection seam, and the full test suite (stubbed API + real SDK against httptest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/fireblocks: RS256 request JWTs (uri/nonce/sub/bodyHash claims with the 60s skew window), RAW and PROGRAM_CALL signing flows with status polling, txHash-is-not-a-signature rejection, local signature verification, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/dfns: User Action Signing flow (init challenge, local credential signing, useraction token), Ed25519/P-256/RSA credential keys via stdlib PKCS#8/SEC1 parsing, serde-exact clientData bytes, r||s signature combining with local verification, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/cdp: EdDSA bearer JWT + ES256 X-Wallet-Auth JWT with the key-sorted reqHash canonicalization, UTF-8-only sign_message quirk, base58 signature / base64 wire-transaction handling with tamper rejection before mutating the input, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of rust/src/crossmint: HKDF-SHA256 delegated-signer key derivation, encodeURIComponent-exact locator encoding, create/poll/approve transaction flow with signature-source precedence and local verification, the intentionally-unsupported sign_message quirk, and the full wiremock test suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Checked deferred body closes, unused test handler params, doc comments on exported const blocks, and a Sprintf->String simplification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Documents the config/New pattern, the HTTPClient and SDK-level override seams, the cross-language sign_message quirks, and the rationale for omitting an umbrella package in Go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Greptile SummaryThis PR brings the Go implementation to full thirteen-backend parity with the Rust and TypeScript implementations, adding
Confidence Score: 5/5All thirteen backends are ready to merge — the three areas added since the last review (turnkey crypto/ecdh derivation, crossmint unsigned-wire-format test pin, openfort unconditional HTTPS check) each check out, and the issues flagged in previous threads are fixed. The turnkey P-256 key derivation correctly uses ecdh.P256().NewPrivateKey for scalar validation then extracts X and Y from the right offsets of the 65-byte uncompressed point, with an end-to-end test verifying ecdsa.VerifyASN1 round-trips. The crossmint unsigned-wire-format test pins [count][zero-sigs][message] across all three languages. The openfort HTTPS check is unconditional and tests correctly use httptest.NewTLSServer. Previously flagged issues (stagger cancellation wrapping, dfns combineSignature length validation, para/openfort SignerError passthrough) are all addressed. No new correctness or security issues found across the 13 backends and shared core. No files require special attention. Important Files Changed
Reviews (11): Last reviewed commit: "test(go): port the live integration test..." | Re-trigger Greptile |
- para, openfort: preserve SignerError codes raised inside the transport (e.g. the HTTPS-only guard's CodeConfigError) instead of re-wrapping as CodeHTTPError, consistent with the other HTTP backends; regression tests added - dfns: require r and s to each be exactly 32 bytes in combineSignature so a misaligned split fails with an accurate error instead of a downstream verification failure; regression test added Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
A context cancelled during the stagger delay surfaced as a raw context.Canceled, breaking the everything-is-a-SignerError contract that the crossmint/fireblocks polling cancellation sites honor. Wrap it as CodeHTTPError with the context error reachable via errors.Is; regression test added (batch.go previously had no test file). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Return the transport-raised SignerError directly (errors.As) instead of re-wrapping its code in a second layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
A non-Solana wallet ID now fails with CodeRemoteAPIError ('expected
Solana wallet, got chain_type=...') like the TypeScript signer, instead
of falling through to CodeInvalidPublicKey; missing address is guarded
the same way. Regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
PROGRAM_CALL signing broadcasts on-chain without a reusable signature; rejecting the returned txHash after the fact risks duplicate spends. Port of the Rust init() rejection (PR #153), plus redacting String().
Request-body tests now assert the exact wire JSON instead of decoding into the same struct. Adds redacting String()/GoString() + leak test.
Approval is now submitted at most once per polling loop, with sleeps between re-polls (async registration no longer hard-fails). The pending approval is selected by our signer locator instead of pending[0], so multi-approver wallets work. HTTPS validation runs even with a custom HTTP client. Ports the two Rust regression tests; adds redaction.
Availability now requires status Active, scheme EdDSA, and curve ed25519 (parity with Rust check_availability); ports the five Rust availability tests and adds redacting String()/GoString() + leak test.
… check The shared HTTP client now refuses every redirect (TS parity): the custom CheckRedirect had removed Go's 10-hop cap, and headers like X-Vault-Token survive cross-host redirects. Vault, Turnkey, and Openfort gain redacting String()/GoString() + leak tests. Para validates HTTPS even when a custom HTTP client is supplied.
memory::tests::parity_vector_dump now asserts the same golden vectors as go/core/parity_test.go (verified on sdk-v2/v3/v4); the Go test's dead placeholder-skip branch is gone.
…ME gaps go-ci.yml gates build/vet/race-tests/golangci-lint on go/ changes. The justfile no longer masks real golangci-lint failures as 'not installed'. Malformed keypair-file contents map to InvalidPrivateKey (read failures stay IOError). README states the Utila and Privy authorization-context gaps and documents the Go zeroization limitation.
… redaction tests awskms.signBytes guarded only CodeConfigError from the transport while every other backend preserves any *core.SignerError code. The redaction tests' deliberate %s-verb coverage gets a justified nolint instead of being rewritten to String(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
Port of the Rust utila module: RS256 service-account JWT auth, transaction initiation + polling, signature extraction verified against the requested message bytes. All 13 Rust tests ported plus redaction and error-path coverage; unsigned transactions are padded with placeholder signatures so the posted payload is byte-identical to Rust/TS.
Quorum wallets: P-256 authorization keys (PKCS#8, wallet-auth:/ wallet-api: forms), canonical-JSON payload identical to the Privy SDK, privy-authorization-signature and privy-request-expiry headers, plus precomputed signatures and external sign functions. Stdlib crypto only; error taxonomy follows Rust (InvalidPrivateKey, parser details never echoed).
'just go-test' now matches the go-ci race-enabled run, the utila redaction test carries the same justified nolint as its siblings, and CLAUDE.md's project overview reflects the three-language, thirteen-backend state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
15 modules: core, testutils, and one per signer, wired with in-repo replace directives (the cargo path-dep pattern; requires get real versions at first tag, core/testutils before signers). Consumers now inherit only their backend's dependency graph — the go 1.25.8 toolchain floor forced by google.golang.org/api is confined to gcpkms; every other module sits at go 1.25 with a pure solana-go graph outside awskms. justfile go recipes and go-ci.yml iterate over every go.mod (build, vet, golangci-lint, race tests per module).
|
@greptile-apps review — bypassing the file limit; the latest push only touched Generated by Claude Code |
curve.ScalarBaseMult is deprecated (staticcheck SA1019, caught by the new Go CI); ecdh.NewPrivateKey also subsumes the manual scalar range check.
solana-go's MarshalBinary already emits a zero placeholder signature per required signer (identical to Rust Transaction::new_unsigned and the TS kit encoding), so the padSignatures helper was a no-op — removed from utila. The new crossmint test asserts the posted bytes are count || zero-placeholders || message so a solana-go regression cannot silently change the wire format.
Aligns with crossmint/para/utila, which validate the scheme regardless of whether a custom HTTPClient is supplied; tests moved to httptest.NewTLSServer and the rejection test now covers both client modes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT
|
@dev-jodee two notes on the latest rounds:
Generated by Claude Code |
resolve-signers gates each signer module behind the same CI_SIGNER_<X>_ENABLED repository variables; go-test fans out as a per-module matrix; go-format and go-lint mirror rust-format/rust-lint. No integration job yet — Rust/TS CI only run live-credential integration tests (vault is justfile-local in all three languages) and Go live tests are still on the deferred list.
Ports all eleven Rust tests/test_<backend>_integration.rs files to Go (-tags=integration): sign-message with local Ed25519 verification, sign-transaction with base64 decode + message roundtrip, availability. crossmint/utila fetch a real blockhash (testutils.GetLatestBlockhash) and sign a minimal empty-instruction transaction, as in Rust. The Rust suite additionally simulates in LiteSVM; no Go bindings exist, so verification stays cryptographic (noted per file). go-ci.yml gains go-integration-test: fork-guarded, Doppler OIDC secrets, AWS/GCP credential steps per module, matrix over the same live-credential backends the TS CI runs (utila and vault remain justfile-local, as in Rust/TS).
|
@greptile-apps review — bypassing the file limit again; since the last full review the branch gained the turnkey Generated by Claude Code |
Summary
Builds on #164 (Go foundation:
core+memory) to bring the Go implementation to full thirteen-backend parity with Rust and TypeScript:Memory · Vault · Privy · Turnkey · AWS KMS · Fireblocks · GCP KMS · Dfns · Crossmint · CDP · Para · Openfort · Utila
What's included
Quality pass on the #164 foundation
Keypair::try_from)memory.Config.PrivateKeyretyped to[]byte— the 32-byte seed form is not a valided25519.PrivateKeyvaluecore.NewHTTPClienthonorsHTTP(S)_PROXYenv config and refuses all redirects (auth headers must never replay against a redirect target)IO_ERROR, malformed contentsINVALID_PRIVATE_KEYThirteen backends (
go/signers/<pkg>)Each is a port of the corresponding Rust module (authoritative spec), with the Rust wiremock unit tests ported to
httptest/stubbed-client tests — race-clean across all 14 packages:vaultvault:vN:prefix stripping, key-metadata health checkturnkeyprivyprivy-app-id, wallet lookup at construction, chain-type guard, authorization-context signingparask_/UUID/HTTPS config validation, hex sign-raw with0xhandling, 5s-bounded availabilityawskmsRAW+ED25519_SHA_512, DescribeKey gate (ECC_NIST_EDWARDS25519, enabled,SIGN_VERIFY)gcpkmsdata, neverDigest,EC_SIGN_ED25519health checkfireblocksUseProgramCallrejected at construction before any network call (PROGRAM_CALL broadcasts on-chain — duplicate-spend risk, parity with Rust #153)dfnscdpreqHash, UTF-8-onlySignMessage, tamper rejectioncrossmintencodeURIComponent-exact locators, create/poll/approve flow,SignMessageintentionally unsupportedopenfortutilaShared conventions across all backends
Configstruct +Newconstructor returning a ready-to-use signer; backends with a Rustinit()take acontext.Contextand initialize inline (analog of RustSigner::from_*/ TS async factories)core.NewHTTPClient; optionalHTTPClient(or SDK-level client) override is the documented escape hatch — the Go analog of Rustwith_clientcore.VerifyEd25519before use, wherever Rust does*core.SignerErrors pass through unwrapped in every backend; untrusted remote text passes throughcore.SanitizeRemoteResponseString()/GoString()on every remote signer; no logging, no globals; signers immutable and concurrency-safe after constructionIntegration & tooling
-tags=integration) mirroringtest_vault_integration.rs, wired to the existingjust go-test-integrationrecipego-ci.yml: build / vet / race-enabled tests / golangci-lint, gated ongo/changes;just go-testruns-raceto matchgolangci-lintclean at zero findings (including--max-same-issues=0)core,testutils, 13 signers) — a memory-only consumer's module graph carries no AWS/GCP SDKs, and thego 1.25.8toolchain floor forced bygoogle.golang.org/apiis confined to thegcpkmsmodule (everything else isgo 1.25)Per-backend Go modules
In-repo
replacedirectives wire the modules together (the cargo path-dep pattern); requires get real versions at first tag, releases taggo/core+go/testutilsbefore the signer modules.justfilego recipes andgo-ci.ymliterate everygo.mod. Until the firstgo/...tags exist,@latestcannot resolve the in-repogo/corerequirement (documented ingo/README.md).Umbrella package: intentionally omitted
The TS umbrella and Rust enum stay lean via tree-shaking / cargo features. Go has no dead-code elimination across a runtime dispatch switch, so an umbrella would force the AWS + GCP SDKs into every consumer's build. Importing the backend package is the Go-native selector; rationale documented in
go/README.md.Known follow-up
Testing
just go-build,just go-fmt(gofmt + vet per module) — cleanjust go-test(go test -race -count=1per module) — 14/14 test packages ok across all 15 modulesgo build -tags=integration ./...— compiles; vault integration runs underjust go-test-integrationDeferred
go/vX.Y.Z), fork-live-tests for Godocs/ADDING_SIGNERS.mdGo section + root README three-language presentation🤖 Generated with Claude Code
https://claude.ai/code/session_012X3zut1xuLEBvfpGGJfoTT