Skip to content
Open
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
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<groupId>com.coinbase.advanced</groupId>
<name>Coinbase Advanced Trade Java SDK</name>
<description>Sample Java SDK for the Coinbase Advanced REST APIs</description>
<version>0.2.1</version>
<version>0.2.2</version>
<url>https://github.com/coinbase-samples/advanced-sdk-java</url>
<licenses>
<license>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -87,51 +100,160 @@ public Map<String, String> generateAuthHeaders(String httpMethod, URI uri, Strin
public String generateJwt(String requestMethod, String host, String path) throws Exception {
Security.addProvider(new BouncyCastleProvider());

Map<String, Object> 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<String, Object> 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:
* <ul>
* <li>ECDSA (P-256), signed with ES256. Supplied as a PEM-encoded key.</li>
* <li>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.</li>
* </ul>
*/
private static PrivateKey loadPrivateKey(String secret) throws Exception {
String trimmed = secret.trim();

JWTClaimsSet.Builder claimsSetBuilder = new JWTClaimsSet.Builder();
for (Map.Entry<String, Object> 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<JWSAlgorithm> supportedJWSAlgorithms() {
return Collections.singleton(JWSAlgorithm.EdDSA);
}

return signedJWT.serialize();
@Override
public JCAContext getJCAContext() {
return jcaContext;
}
}
}
2 changes: 1 addition & 1 deletion src/main/java/com/coinbase/advanced/utils/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down