Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<description>Java SDK for the Coinbase Prime REST APIs</description>
<groupId>com.coinbase.prime</groupId>
<url>https://github.com/coinbase/prime-sdk-java</url>
<version>1.10.2</version>
<version>1.10.3</version>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
Expand All @@ -48,7 +48,7 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.11</java.version>
<jackson.version>2.18.6</jackson.version>
<core.version>1.1.1</core.version>
<core.version>1.2.1</core.version>
<junit.version>5.10.0</junit.version>
</properties>
<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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. */
Expand Down Expand Up @@ -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<String, String> 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);

Expand All @@ -128,14 +175,19 @@ public Map<String, String> 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);
Expand Down Expand Up @@ -179,6 +231,7 @@ public static class Builder {
private String passphrase;
private String signingKey;
private String svcAccountId;
private Signer signer;

public Builder() {}

Expand All @@ -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);
Expand All @@ -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");
Comment thread
rcbgr marked this conversation as resolved.
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/coinbase/prime/utils/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String, String> 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<String, String> 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<byte[]> 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<String, String> 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<String, String> 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());
}
}
Loading
Loading