mirror of
https://github.com/we-promise/sure.git
synced 2026-07-16 23:05:22 +00:00
* fix: use ES256 instead of EdDSA for Coinbase CDP JWT signing Coinbase Developer Platform issues EC P-256 keys, not Ed25519. The previous EdDSA implementation would always return Unauthorized for any key generated via portal.cdp.coinbase.com. Switch to ES256 (ECDSA SHA-256) using OpenSSL, and convert the DER-encoded signature to the raw r||s format required by the JWT spec. API Secret field now accepts the full PEM private key directly (including BEGIN/END headers) as provided by Coinbase. * fix(coinbase): address PR review comments - Normalize escaped \n in PEM key before parsing (fixes pasting directly from the Coinbase CDP JSON download file where newlines are \n literals) - Extract parse_ec_private_key helper with docstring explaining both accepted PEM formats - Fix double-space style nit on encoded_header assignment - Remove ed25519 gem from Gemfile (no longer used after ES256 migration) - Add Provider::CoinbaseTest covering: JWT 3-part structure, ES256 alg header, kid claim, CDP payload claims, 64-byte raw r||s signature, escaped-newline key acceptance, and cryptographic signature verification * test(coinbase): fix base64url padding in JWT test assertions * chore: update Gemfile.lock to remove ed25519 gem * Sample credential in test * Comments field, not real key use * test(coinbase): generate EC key dynamically; drop scanner exclusions Generate a fresh P-256 key per test run with OpenSSL::PKey::EC.generate instead of hardcoding a PEM private key. This removes committed key material and the need to exclude the provider and test files from the pipelock secret scan, so those exclusions are removed too. Addresses review findings on hardcoded test key + scan exclusions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
102 lines
3.9 KiB
Ruby
102 lines
3.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "base64"
|
|
require "json"
|
|
require "openssl"
|
|
|
|
class Provider::CoinbaseTest < ActiveSupport::TestCase
|
|
TEST_API_KEY = "organizations/test-org/apiKeys/test-key-id".freeze
|
|
|
|
# JWT base64url parts omit padding — add the correct amount before decoding.
|
|
def jwt_decode_part(str)
|
|
pad = (4 - str.length % 4) % 4
|
|
Base64.urlsafe_decode64(str + ("=" * pad))
|
|
end
|
|
|
|
setup do
|
|
# Generate a fresh P-256 (prime256v1) key per run so no private key material
|
|
# is ever committed to the repo — avoids hardcoded-secret scanner findings.
|
|
ec = OpenSSL::PKey::EC.generate("prime256v1")
|
|
@test_key_multiline = ec.to_pem
|
|
# Simulate the common CDP "JSON paste" format where newlines are escaped as \n.
|
|
@test_key_single_line = @test_key_multiline.gsub("\n", '\n')
|
|
|
|
@provider = Provider::Coinbase.new(api_key: TEST_API_KEY, api_secret: @test_key_multiline)
|
|
@provider_escaped = Provider::Coinbase.new(api_key: TEST_API_KEY, api_secret: @test_key_single_line)
|
|
end
|
|
|
|
# --- parse_ec_private_key ---
|
|
|
|
test "parse_ec_private_key accepts real-newline PEM" do
|
|
key = @provider.send(:parse_ec_private_key, @test_key_multiline)
|
|
assert_instance_of OpenSSL::PKey::EC, key
|
|
end
|
|
|
|
test "parse_ec_private_key accepts escaped-newline PEM (JSON paste format)" do
|
|
key = @provider.send(:parse_ec_private_key, @test_key_single_line)
|
|
assert_instance_of OpenSSL::PKey::EC, key
|
|
end
|
|
|
|
# --- generate_jwt structure ---
|
|
|
|
test "generate_jwt produces a three-part JWT" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
parts = jwt.split(".")
|
|
assert_equal 3, parts.length, "JWT must have header.payload.signature"
|
|
end
|
|
|
|
test "generate_jwt header has alg ES256" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
header = JSON.parse(jwt_decode_part(jwt.split(".").first))
|
|
assert_equal "ES256", header["alg"]
|
|
end
|
|
|
|
test "generate_jwt header has kid matching api_key" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
header = JSON.parse(jwt_decode_part(jwt.split(".").first))
|
|
assert_equal TEST_API_KEY, header["kid"]
|
|
end
|
|
|
|
test "generate_jwt payload includes required CDP claims" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
payload = JSON.parse(jwt_decode_part(jwt.split(".")[1]))
|
|
|
|
assert_equal TEST_API_KEY, payload["sub"]
|
|
assert_equal "cdp", payload["iss"]
|
|
assert_equal "GET api.coinbase.com/v2/user", payload["uri"]
|
|
assert payload["nbf"].is_a?(Integer)
|
|
assert payload["exp"].is_a?(Integer)
|
|
assert_equal 120, payload["exp"] - payload["nbf"]
|
|
end
|
|
|
|
test "generate_jwt signature is 64 bytes (raw r||s for ES256)" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
sig_bytes = Base64.urlsafe_decode64(jwt.split(".").last + "==")
|
|
assert_equal 64, sig_bytes.bytesize, "ES256 JWT signature must be exactly 64 bytes (r||s)"
|
|
end
|
|
|
|
test "generate_jwt works with escaped-newline PEM key" do
|
|
jwt = @provider_escaped.send(:generate_jwt, "GET", "/v2/user")
|
|
header = JSON.parse(Base64.urlsafe_decode64(jwt.split(".").first + "=="))
|
|
assert_equal "ES256", header["alg"]
|
|
end
|
|
|
|
test "generate_jwt signature is verifiable with the corresponding public key" do
|
|
jwt = @provider.send(:generate_jwt, "GET", "/v2/user")
|
|
encoded_header, encoded_payload, encoded_sig = jwt.split(".")
|
|
|
|
public_key = OpenSSL::PKey::EC.new(@test_key_multiline)
|
|
message = "#{encoded_header}.#{encoded_payload}"
|
|
|
|
# Re-encode raw r||s signature back to DER for OpenSSL verification
|
|
sig_bytes = Base64.urlsafe_decode64(encoded_sig + "==")
|
|
r = OpenSSL::BN.new(sig_bytes[0, 32], 2)
|
|
s = OpenSSL::BN.new(sig_bytes[32, 32], 2)
|
|
der_sig = OpenSSL::ASN1::Sequence([ OpenSSL::ASN1::Integer(r), OpenSSL::ASN1::Integer(s) ]).to_der
|
|
|
|
assert public_key.verify(OpenSSL::Digest::SHA256.new, der_sig, message),
|
|
"JWT signature must verify against the EC public key"
|
|
end
|
|
end
|