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