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"; }