diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d9d21..13d99d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.10.3] - 2026-JUL-28 + +### Added + +- Added custom signer support for separate signing key storage. + ## [1.10.1] - 2026-JUN-23 ### Fixed diff --git a/pom.xml b/pom.xml index c98ed02..43ae762 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ Java SDK for the Coinbase Prime REST APIs com.coinbase.prime https://github.com/coinbase/prime-sdk-java - 1.10.2 + 1.10.3 Apache License, Version 2.0 @@ -47,8 +47,8 @@ UTF-8 UTF-8 1.11 - 2.18.6 - 1.1.1 + 2.18.9 + 1.2.1 5.10.0 @@ -87,9 +87,9 @@ org.apache.maven.plugins maven-compiler-plugin + 3.13.0 - 11 - 11 + 11 diff --git a/src/main/java/com/coinbase/examples/credentials/ListAssetsWithCustomSigner.java b/src/main/java/com/coinbase/examples/credentials/ListAssetsWithCustomSigner.java new file mode 100644 index 0000000..592fe2e --- /dev/null +++ b/src/main/java/com/coinbase/examples/credentials/ListAssetsWithCustomSigner.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026-present Coinbase Global, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.coinbase.examples.credentials; + +import com.coinbase.core.credentials.Signer; +import com.coinbase.prime.assets.AssetsService; +import com.coinbase.prime.assets.ListAssetsRequest; +import com.coinbase.prime.assets.ListAssetsResponse; +import com.coinbase.prime.client.CoinbasePrimeClient; +import com.coinbase.prime.credentials.CoinbasePrimeCredentials; +import com.coinbase.prime.factory.PrimeServiceFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Same call as {@code ListAssets}, but signs requests with a custom {@link Signer} instead of the + * SDK's built-in HMAC-SHA256 implementation. In production, {@link Signer#sign} would call out to + * an HSM/KMS; this example signs locally to keep it runnable end to end. + * + *

Expects {@code COINBASE_PRIME_CREDENTIALS} to be JSON containing only {@code accessKey} and + * {@code passphrase} (no {@code signingKey} needed), plus {@code COINBASE_PRIME_ENTITY_ID}. + */ +public class ListAssetsWithCustomSigner { + public static void main(String[] args) { + try { + Signer hsmSigner = new LocalHmacStandInForHsm(System.getenv("SIGNING_KEY")); + + CoinbasePrimeCredentials credentials = + new CoinbasePrimeCredentials(System.getenv("COINBASE_PRIME_CREDENTIALS"), hsmSigner); + CoinbasePrimeClient client = new CoinbasePrimeClient(credentials); + String entityId = System.getenv("COINBASE_PRIME_ENTITY_ID"); + + AssetsService service = PrimeServiceFactory.createAssetsService(client); + ListAssetsResponse response = + service.listAssets(new ListAssetsRequest.Builder().entityId(entityId).build()); + + System.out.println( + new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(response)); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Stands in for an HSM/KMS client. Receives the exact message bytes the SDK would otherwise hash + * itself ({@code timestamp + method + path + body}), returns raw signature bytes — no string + * encoding, no Base64. The SDK Base64-encodes the result before attaching it as the {@code + * X-CB-ACCESS-SIGNATURE} header. + */ + private static class LocalHmacStandInForHsm implements Signer { + private final byte[] keyBytes; + + LocalHmacStandInForHsm(String signingKey) { + this.keyBytes = signingKey.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + @Override + public byte[] sign(byte[] message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(keyBytes, "HmacSHA256")); + return mac.doFinal(message); + } catch (Exception e) { + throw new RuntimeException("HSM sign call failed", e); + } + } + } +} diff --git a/src/main/java/com/coinbase/prime/credentials/CoinbasePrimeCredentials.java b/src/main/java/com/coinbase/prime/credentials/CoinbasePrimeCredentials.java index fae879d..dff0d03 100644 --- a/src/main/java/com/coinbase/prime/credentials/CoinbasePrimeCredentials.java +++ b/src/main/java/com/coinbase/prime/credentials/CoinbasePrimeCredentials.java @@ -18,12 +18,14 @@ import static com.coinbase.core.utils.Utils.isNullOrEmpty; import com.coinbase.core.credentials.CoinbaseCredentials; +import com.coinbase.core.credentials.Signer; import com.coinbase.core.errors.CoinbaseClientException; import com.coinbase.prime.utils.Constants; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Base64; import java.util.Map; import javax.crypto.Mac; @@ -42,6 +44,8 @@ public class CoinbasePrimeCredentials implements CoinbaseCredentials { @JsonProperty(required = false) private String svcAccountId; + private Signer signer; + public CoinbasePrimeCredentials(String credentialsJson) throws CoinbaseClientException { ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -55,6 +59,48 @@ public CoinbasePrimeCredentials(String credentialsJson) throws CoinbaseClientExc } catch (Throwable e) { throw new CoinbaseClientException("Failed to parse credentials", e); } + + if (isNullOrEmpty(this.accessKey)) { + throw new CoinbaseClientException("Access key is required"); + } + if (isNullOrEmpty(this.passphrase)) { + throw new CoinbaseClientException("Passphrase is required"); + } + if (isNullOrEmpty(this.signingKey)) { + throw new CoinbaseClientException("Signing key is required"); + } + } + + /** + * Constructor for a custom {@link Signer} (e.g. an HSM-backed signer), parsed from the same + * credentials JSON as {@link #CoinbasePrimeCredentials(String)}. The signing key is managed + * entirely by the {@link Signer} implementation, so {@code signingKey} is not required in the + * JSON for this constructor. + */ + public CoinbasePrimeCredentials(String credentialsJson, Signer signer) + throws CoinbaseClientException { + ObjectMapper mapper = new ObjectMapper(); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + try { + CoinbasePrimeCredentials credentials = + mapper.readValue(credentialsJson, CoinbasePrimeCredentials.class); + this.accessKey = credentials.getAccessKey(); + this.passphrase = credentials.getPassphrase(); + this.svcAccountId = credentials.getSvcAccountId(); + } catch (Throwable e) { + throw new CoinbaseClientException("Failed to parse credentials", e); + } + + if (isNullOrEmpty(this.accessKey)) { + throw new CoinbaseClientException("Access key is required"); + } + if (isNullOrEmpty(this.passphrase)) { + throw new CoinbaseClientException("Passphrase is required"); + } + if (signer == null) { + throw new CoinbaseClientException("Signer is required"); + } + this.signer = signer; } /** Constructor for the standard REST API. */ @@ -109,12 +155,13 @@ public CoinbasePrimeCredentials(Builder builder) { this.passphrase = builder.passphrase; this.signingKey = builder.signingKey; this.svcAccountId = builder.svcAccountId; + this.signer = builder.signer; } @Override public Map generateAuthHeaders(String method, java.net.URI uri, String body) throws CoinbaseClientException { - long timestamp = System.currentTimeMillis() / 1000; + long timestamp = Instant.now().getEpochSecond(); String path = uri.getPath(); String signature = sign(timestamp, method, path, body); @@ -128,14 +175,19 @@ public Map generateAuthHeaders(String method, java.net.URI uri, private String sign(long timestamp, String method, String path, String body) throws CoinbaseClientException { - try { - String message = String.format("%s%s%s%s", timestamp, method, path, body); + String message = String.format("%s%s%s%s", timestamp, method, path, body); + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + + if (this.signer != null) { + return Base64.getEncoder().encodeToString(this.signer.sign(messageBytes)); + } + try { byte[] hmacKey = this.signingKey.getBytes(StandardCharsets.UTF_8); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(hmacKey, "HmacSHA256")); - byte[] signature = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); + byte[] signature = mac.doFinal(messageBytes); return Base64.getEncoder().encodeToString(signature); } catch (Throwable e) { throw new CoinbaseClientException("Failed to generate signature", e); @@ -179,6 +231,7 @@ public static class Builder { private String passphrase; private String signingKey; private String svcAccountId; + private Signer signer; public Builder() {} @@ -202,6 +255,15 @@ public Builder svcAccountId(String svcAccountId) { return this; } + /** + * Sets a custom {@link Signer} (e.g. an HSM-backed signer) to use instead of the built-in + * HMAC-SHA256 implementation. When set, {@code signingKey} is not required. + */ + public Builder signer(Signer signer) { + this.signer = signer; + return this; + } + public CoinbaseCredentials build() throws CoinbaseClientException { this.validate(); return new CoinbasePrimeCredentials(this); @@ -214,7 +276,7 @@ private void validate() throws CoinbaseClientException { if (isNullOrEmpty(this.passphrase)) { throw new CoinbaseClientException("Passphrase is required"); } - if (isNullOrEmpty(this.signingKey)) { + if (this.signer == null && isNullOrEmpty(this.signingKey)) { throw new CoinbaseClientException("Signing key is required"); } } diff --git a/src/main/java/com/coinbase/prime/utils/Constants.java b/src/main/java/com/coinbase/prime/utils/Constants.java index 8262d8d..cdc27aa 100644 --- a/src/main/java/com/coinbase/prime/utils/Constants.java +++ b/src/main/java/com/coinbase/prime/utils/Constants.java @@ -23,7 +23,7 @@ public class Constants { public static final String CB_ACCESS_TIMESTAMP_HEADER = "X-CB-ACCESS-TIMESTAMP"; public static final String CB_USER_AGENT_HEADER = "User-Agent"; public static final String CB_PRIME_BASE_URL = "https://api.prime.coinbase.com/v1"; - public static final String SDK_VERSION = "1.9.0"; + public static final String SDK_VERSION = "1.10.3"; /** * Replaces a trailing {@code /vN} segment with {@code /}{@code version}. Used when an endpoint is diff --git a/src/test/java/com/coinbase/prime/credentials/CoinbasePrimeCredentialsTest.java b/src/test/java/com/coinbase/prime/credentials/CoinbasePrimeCredentialsTest.java index d05545a..464b064 100644 --- a/src/test/java/com/coinbase/prime/credentials/CoinbasePrimeCredentialsTest.java +++ b/src/test/java/com/coinbase/prime/credentials/CoinbasePrimeCredentialsTest.java @@ -18,10 +18,15 @@ import static org.junit.jupiter.api.Assertions.*; +import com.coinbase.core.credentials.Signer; import com.coinbase.core.errors.CoinbaseClientException; import com.coinbase.prime.utils.Constants; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -100,4 +105,110 @@ public void testAllRequiredHeadersArePresent() throws CoinbaseClientException { assertTrue( headers.containsKey(Constants.CB_USER_AGENT_HEADER), "User-Agent header should be present"); } + + @Test + public void testDefaultSignatureMatchesIndependentlyComputedHmacSha256() throws Exception { + URI testUri = URI.create("https://api.prime.coinbase.com/v1/portfolios"); + Map headers = credentials.generateAuthHeaders("GET", testUri, ""); + + // Recompute the expected signature independently of CoinbasePrimeCredentials#sign, using the + // exact timestamp it produced, to confirm the real HMAC-SHA256 path (not a stubbed Signer) + // produces a byte-correct signature for a known key/message. + String timestamp = headers.get(Constants.CB_ACCESS_TIMESTAMP_HEADER); + String message = timestamp + "GET" + testUri.getPath(); + + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("test-signing-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String expectedSignature = + Base64.getEncoder().encodeToString(mac.doFinal(message.getBytes(StandardCharsets.UTF_8))); + + assertEquals(expectedSignature, headers.get(Constants.CB_ACCESS_SIGNATURE_HEADER)); + } + + private static final String CREDENTIALS_JSON_WITHOUT_SIGNING_KEY = + "{\"accessKey\":\"test-access-key\",\"passphrase\":\"test-passphrase\"}"; + + @Test + public void testCustomSignerIsUsedForSignature() throws CoinbaseClientException { + byte[] fixedSignature = "custom-signature".getBytes(StandardCharsets.UTF_8); + Signer customSigner = message -> fixedSignature; + CoinbasePrimeCredentials customCredentials = + new CoinbasePrimeCredentials(CREDENTIALS_JSON_WITHOUT_SIGNING_KEY, customSigner); + + URI testUri = URI.create("https://api.prime.coinbase.com/v1/portfolios"); + Map headers = customCredentials.generateAuthHeaders("GET", testUri, ""); + + assertEquals( + Base64.getEncoder().encodeToString(fixedSignature), + headers.get(Constants.CB_ACCESS_SIGNATURE_HEADER), + "Signature header should be the Base64 encoding of the custom Signer's output"); + } + + @Test + public void testCustomSignerReceivesAssembledMessageBytes() throws CoinbaseClientException { + java.util.concurrent.atomic.AtomicReference capturedMessage = + new java.util.concurrent.atomic.AtomicReference<>(); + Signer customSigner = + message -> { + capturedMessage.set(message); + return "irrelevant-signature".getBytes(StandardCharsets.UTF_8); + }; + CoinbasePrimeCredentials customCredentials = + new CoinbasePrimeCredentials(CREDENTIALS_JSON_WITHOUT_SIGNING_KEY, customSigner); + + URI testUri = URI.create("https://api.prime.coinbase.com/v1/portfolios"); + Map headers = customCredentials.generateAuthHeaders("GET", testUri, ""); + + String timestamp = headers.get(Constants.CB_ACCESS_TIMESTAMP_HEADER); + String expectedMessage = timestamp + "GET" + testUri.getPath(); + assertArrayEquals(expectedMessage.getBytes(StandardCharsets.UTF_8), capturedMessage.get()); + } + + @Test + public void testJsonConstructorRequiresSignerNonNull() { + assertThrows( + CoinbaseClientException.class, + () -> new CoinbasePrimeCredentials(CREDENTIALS_JSON_WITHOUT_SIGNING_KEY, null)); + } + + @Test + public void testJsonConstructorWithSignerRequiresAccessKey() { + Signer customSigner = message -> "custom-signature".getBytes(StandardCharsets.UTF_8); + assertThrows( + CoinbaseClientException.class, + () -> new CoinbasePrimeCredentials("{\"passphrase\":\"test-passphrase\"}", customSigner)); + } + + @Test + public void testBuilderDoesNotRequireSigningKeyWhenSignerIsSet() throws CoinbaseClientException { + byte[] fixedSignature = "custom-signature".getBytes(StandardCharsets.UTF_8); + Signer customSigner = message -> fixedSignature; + CoinbasePrimeCredentials customCredentials = + (CoinbasePrimeCredentials) + new CoinbasePrimeCredentials.Builder() + .accessKey("test-access-key") + .passphrase("test-passphrase") + .signer(customSigner) + .build(); + + URI testUri = URI.create("https://api.prime.coinbase.com/v1/portfolios"); + Map headers = customCredentials.generateAuthHeaders("GET", testUri, ""); + + assertEquals( + Base64.getEncoder().encodeToString(fixedSignature), + headers.get(Constants.CB_ACCESS_SIGNATURE_HEADER)); + } + + @Test + public void testBuilderStillRequiresSigningKeyWithoutSigner() { + CoinbaseClientException exception = + assertThrows( + CoinbaseClientException.class, + () -> + new CoinbasePrimeCredentials.Builder() + .accessKey("test-access-key") + .passphrase("test-passphrase") + .build()); + assertEquals("Signing key is required", exception.getMessage()); + } } diff --git a/src/test/java/com/coinbase/prime/integration/VersionHeaderIntegrationTest.java b/src/test/java/com/coinbase/prime/integration/VersionHeaderIntegrationTest.java index 1666191..186b8a6 100644 --- a/src/test/java/com/coinbase/prime/integration/VersionHeaderIntegrationTest.java +++ b/src/test/java/com/coinbase/prime/integration/VersionHeaderIntegrationTest.java @@ -47,9 +47,9 @@ public void testVersionHeaderMatchesPomVersion() throws CoinbaseClientException assertNotNull(userAgent, "User-Agent header should be present"); assertEquals( - "prime-sdk-java/1.9.0", + "prime-sdk-java/1.10.3", userAgent, - "User-Agent should contain 'prime-sdk-java/1.9.0' to match pom.xml version"); + "User-Agent should contain 'prime-sdk-java/1.10.3' to match pom.xml version"); // Additional verification: Ensure the SDK_VERSION constant is used correctly String expectedUserAgent = String.format("prime-sdk-java/%s", Constants.SDK_VERSION); @@ -63,9 +63,9 @@ public void testVersionHeaderMatchesPomVersion() throws CoinbaseClientException public void testSdkVersionConstantMatches() { // Verify that the SDK_VERSION constant has been updated correctly assertEquals( - "1.9.0", + "1.10.3", Constants.SDK_VERSION, - "SDK_VERSION constant should be '1.9.0' to match pom.xml version"); + "SDK_VERSION constant should be '1.10.3' to match pom.xml version"); } @Test @@ -84,7 +84,7 @@ public void testAllApiCallsWillIncludeVersionHeader() throws CoinbaseClientExcep String userAgent = headers.get(Constants.CB_USER_AGENT_HEADER); assertEquals( - "prime-sdk-java/1.9.0", + "prime-sdk-java/1.10.3", userAgent, "All API calls should include the correct version header, failed for path: " + path); } diff --git a/src/test/java/com/coinbase/prime/utils/ConstantsTest.java b/src/test/java/com/coinbase/prime/utils/ConstantsTest.java index dea26c8..69cdd07 100644 --- a/src/test/java/com/coinbase/prime/utils/ConstantsTest.java +++ b/src/test/java/com/coinbase/prime/utils/ConstantsTest.java @@ -24,7 +24,8 @@ public class ConstantsTest { @Test public void testSdkVersionIsCorrect() { - assertEquals("1.9.0", Constants.SDK_VERSION, "SDK_VERSION should match the version in pom.xml"); + assertEquals( + "1.10.3", Constants.SDK_VERSION, "SDK_VERSION should match the version in pom.xml"); } @Test diff --git a/tools/model-generator/pom.xml b/tools/model-generator/pom.xml index ad96db7..10948cd 100644 --- a/tools/model-generator/pom.xml +++ b/tools/model-generator/pom.xml @@ -18,7 +18,7 @@ 11 UTF-8 7.14.0 - 2.16.1 + 2.18.9 1.13.0 2.2