diff --git a/pom.xml b/pom.xml
index edce0ad..e4bcf29 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
com.coinbase.advanced
Coinbase Advanced Trade Java SDK
Sample Java SDK for the Coinbase Advanced REST APIs
- 0.2.1
+ 0.2.2
https://github.com/coinbase-samples/advanced-sdk-java
diff --git a/src/main/java/com/coinbase/advanced/credentials/CoinbaseAdvancedCredentials.java b/src/main/java/com/coinbase/advanced/credentials/CoinbaseAdvancedCredentials.java
index 978ef0e..c5b8048 100644
--- a/src/main/java/com/coinbase/advanced/credentials/CoinbaseAdvancedCredentials.java
+++ b/src/main/java/com/coinbase/advanced/credentials/CoinbaseAdvancedCredentials.java
@@ -23,21 +23,34 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jose.jca.JCAContext;
+import com.nimbusds.jose.util.Base64URL;
import com.nimbusds.jwt.*;
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters;
+import org.bouncycastle.crypto.util.PrivateKeyInfoFactory;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import java.io.StringReader;
import java.net.URI;
+import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
+import java.security.SecureRandom;
import java.security.Security;
+import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Date;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
public class CoinbaseAdvancedCredentials implements CoinbaseCredentials {
@JsonProperty(required = true)
@@ -87,51 +100,160 @@ public Map generateAuthHeaders(String httpMethod, URI uri, Strin
public String generateJwt(String requestMethod, String host, String path) throws Exception {
Security.addProvider(new BouncyCastleProvider());
- Map header = new HashMap<>();
- header.put("alg", "ES256");
- header.put("typ", "JWT");
- header.put("kid", apiKeyName);
- header.put("nonce", String.valueOf(Instant.now().getEpochSecond()));
+ PrivateKey key = loadPrivateKey(privateKey);
+ JWSAlgorithm algorithm = algorithmFor(key);
String uri = requestMethod + " " + host + path;
+ long now = Instant.now().getEpochSecond();
- Map data = new HashMap<>();
- data.put("iss", "cdp");
- data.put("nbf", Instant.now().getEpochSecond());
- data.put("exp", Instant.now().getEpochSecond() + 120);
- data.put("sub", apiKeyName);
- data.put("uri", uri);
-
- try (PEMParser pemParser = new PEMParser(new StringReader(privateKey))) {
- JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
- Object object = pemParser.readObject();
- PrivateKey privateKey;
-
- if (object instanceof PrivateKey) {
- privateKey = (PrivateKey) object;
- } else if (object instanceof org.bouncycastle.openssl.PEMKeyPair) {
- privateKey = converter.getPrivateKey(((org.bouncycastle.openssl.PEMKeyPair) object).getPrivateKeyInfo());
- } else {
- throw new Exception("Unexpected private key format");
- }
+ JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
+ .subject(apiKeyName)
+ .issuer("cdp")
+ .notBeforeTime(Date.from(Instant.ofEpochSecond(now)))
+ .expirationTime(Date.from(Instant.ofEpochSecond(now + 120)))
+ .claim("uri", uri)
+ .build();
+
+ JWSHeader jwsHeader = new JWSHeader.Builder(algorithm)
+ .type(JOSEObjectType.JWT)
+ .keyID(apiKeyName)
+ .customParam("nonce", generateNonce())
+ .build();
+
+ SignedJWT signedJWT = new SignedJWT(jwsHeader, claimsSet);
+ signedJWT.sign(signerFor(algorithm, key));
+
+ return signedJWT.serialize();
+ }
- KeyFactory keyFactory = KeyFactory.getInstance("EC");
- PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKey.getEncoded());
- ECPrivateKey ecPrivateKey = (ECPrivateKey) keyFactory.generatePrivate(keySpec);
+ /**
+ * Loads a CDP API key secret into a {@link PrivateKey}. Two key types are
+ * supported:
+ *
+ * - ECDSA (P-256), signed with ES256. Supplied as a PEM-encoded key.
+ * - Ed25519, signed with EdDSA. Supplied either as a PKCS#8 PEM key or as
+ * a base64-encoded raw key (32-byte seed or 64-byte seed+public key),
+ * which is how the CDP portal hands out Ed25519 secrets.
+ *
+ */
+ private static PrivateKey loadPrivateKey(String secret) throws Exception {
+ String trimmed = secret.trim();
- JWTClaimsSet.Builder claimsSetBuilder = new JWTClaimsSet.Builder();
- for (Map.Entry entry : data.entrySet()) {
- claimsSetBuilder.claim(entry.getKey(), entry.getValue());
+ if (trimmed.startsWith("-----BEGIN")) {
+ PrivateKey key;
+ try (PEMParser pemParser = new PEMParser(new StringReader(secret))) {
+ JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
+ Object object = pemParser.readObject();
+ if (object instanceof PEMKeyPair) {
+ key = converter.getPrivateKey(((PEMKeyPair) object).getPrivateKeyInfo());
+ } else if (object instanceof PrivateKeyInfo) {
+ key = converter.getPrivateKey((PrivateKeyInfo) object);
+ } else if (object instanceof PrivateKey) {
+ key = (PrivateKey) object;
+ } else {
+ throw new CoinbaseClientException("Unexpected private key format");
+ }
}
- JWTClaimsSet claimsSet = claimsSetBuilder.build();
+ // Normalize EC keys through the default provider so nimbus signs them
+ // exactly as before; Ed25519 keys are returned as-is for the BC signer.
+ if (isEc(key.getAlgorithm())) {
+ KeyFactory keyFactory = KeyFactory.getInstance("EC");
+ return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(key.getEncoded()));
+ }
+ return key;
+ }
- JWSHeader jwsHeader = new JWSHeader.Builder(JWSAlgorithm.ES256).customParams(header).build();
- SignedJWT signedJWT = new SignedJWT(jwsHeader, claimsSet);
+ byte[] raw;
+ try {
+ raw = Base64.getDecoder().decode(trimmed.replaceAll("\\s", ""));
+ } catch (IllegalArgumentException e) {
+ throw new CoinbaseClientException("private key is neither PEM nor valid base64", e);
+ }
+ if (raw.length != 32 && raw.length != 64) {
+ throw new CoinbaseClientException(
+ "Ed25519 raw key must decode to 32 or 64 bytes, got " + raw.length);
+ }
+ // The constructor reads the 32-byte seed from offset 0, ignoring the
+ // trailing public key bytes when a 64-byte key is supplied.
+ Ed25519PrivateKeyParameters params = new Ed25519PrivateKeyParameters(raw, 0);
+ PrivateKeyInfo keyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(params);
+ KeyFactory keyFactory = KeyFactory.getInstance("Ed25519", "BC");
+ return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyInfo.getEncoded()));
+ }
+
+ private static JWSAlgorithm algorithmFor(PrivateKey key) {
+ String alg = key.getAlgorithm();
+ if (isEd25519(alg)) {
+ return JWSAlgorithm.EdDSA;
+ }
+ if (isEc(alg)) {
+ return JWSAlgorithm.ES256;
+ }
+ throw new IllegalArgumentException(
+ "Unsupported private key type: " + alg + ". Expected ECDSA (P-256) or Ed25519.");
+ }
- JWSSigner signer = new ECDSASigner(ecPrivateKey);
- signedJWT.sign(signer);
+ private static JWSSigner signerFor(JWSAlgorithm algorithm, PrivateKey key) throws JOSEException {
+ if (JWSAlgorithm.EdDSA.equals(algorithm)) {
+ return new Ed25519JwsSigner(key);
+ }
+ return new ECDSASigner((ECPrivateKey) key);
+ }
+
+ private static boolean isEc(String alg) {
+ return "EC".equalsIgnoreCase(alg) || "ECDSA".equalsIgnoreCase(alg);
+ }
+
+ private static boolean isEd25519(String alg) {
+ return "Ed25519".equalsIgnoreCase(alg) || "EdDSA".equalsIgnoreCase(alg);
+ }
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private static String generateNonce() {
+ byte[] bytes = new byte[16];
+ SECURE_RANDOM.nextBytes(bytes);
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+ sb.append(Character.forDigit(b & 0xF, 16));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * A nimbus {@link JWSSigner} for EdDSA backed by BouncyCastle, so Ed25519
+ * signing works without pulling in the Tink dependency nimbus otherwise
+ * requires for OKP keys.
+ */
+ private static final class Ed25519JwsSigner implements JWSSigner {
+ private final PrivateKey privateKey;
+ private final JCAContext jcaContext = new JCAContext();
+
+ Ed25519JwsSigner(PrivateKey privateKey) {
+ this.privateKey = privateKey;
+ }
+
+ @Override
+ public Base64URL sign(JWSHeader header, byte[] signingInput) throws JOSEException {
+ try {
+ Signature signature = Signature.getInstance("Ed25519", BouncyCastleProvider.PROVIDER_NAME);
+ signature.initSign(privateKey);
+ signature.update(signingInput);
+ return Base64URL.encode(signature.sign());
+ } catch (GeneralSecurityException e) {
+ throw new JOSEException("Ed25519 signing failed: " + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public Set supportedJWSAlgorithms() {
+ return Collections.singleton(JWSAlgorithm.EdDSA);
+ }
- return signedJWT.serialize();
+ @Override
+ public JCAContext getJCAContext() {
+ return jcaContext;
}
}
}
diff --git a/src/main/java/com/coinbase/advanced/utils/Constants.java b/src/main/java/com/coinbase/advanced/utils/Constants.java
index 3aabde2..afbe65f 100644
--- a/src/main/java/com/coinbase/advanced/utils/Constants.java
+++ b/src/main/java/com/coinbase/advanced/utils/Constants.java
@@ -18,7 +18,7 @@
public class Constants {
public static final String BASE_URL = "https://api.coinbase.com/api/v3";
- public static final String SDK_VERSION = "0.1.0";
+ public static final String SDK_VERSION = "0.2.2";
public static final String USER_AGENT_HEADER = "User-Agent";
public static final String AUTH_HEADER = "Authorization";
}