Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 5 additions & 5 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 @@ -47,8 +47,8 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<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>
<jackson.version>2.18.9</jackson.version>
<core.version>1.2.1</core.version>
<junit.version>5.10.0</junit.version>
</properties>
<build>
Expand Down Expand Up @@ -87,9 +87,9 @@
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>11</source>
<target>11</target>
<release>11</release>
</configuration>
</plugin>
<plugin>
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
Loading
Loading