Skip to content

Commit 8f315f8

Browse files
committed
add decrypt helper for rotating api key
1 parent 317291a commit 8f315f8

6 files changed

Lines changed: 416 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
**ApiKey Service**
1010
- **`rotateApiKey()`**: Rotate the invoking API key (`POST /v1/api-keys/rotate`)
11+
- **`ApiKeyRotation.decrypt()`**: Decrypt `encrypted_credentials` with JDK HKDF-SHA256 + AES-256-GCM (no extra libraries)
1112

1213
**Financing Service**
1314
- **`getConversionFees()`**: Get organization conversion fee tiers and month-to-date volume (`GET /v1/conversion/fees`)

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ mvn exec:java -Dexec.mainClass="com.coinbase.examples.transactions.ListPortfolio
142142
- `com.coinbase.examples.wallets.GetWalletDepositInstructions <wallet-id> [deposit-type]` - Get deposit instructions (deposit-type: CRYPTO, WIRE, SEN, SWIFT, SEPA)
143143

144144
**API Key:**
145-
- `com.coinbase.examples.apikey.RotateApiKey` - Rotate the invoking API key (starts a real rotation)
145+
- `com.coinbase.examples.apikey.RotateApiKey` - Rotate the invoking API key (starts a real rotation). Decrypts `encrypted_credentials` via `ApiKeyRotation.decrypt` using the current `signingKey`.
146146

147147
**Financing:**
148148
- `com.coinbase.examples.financing.GetConversionFees` - Get organization conversion fee tiers

‎src/main/java/com/coinbase/examples/apikey/RotateApiKey.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
package com.coinbase.examples.apikey;
1818

19+
import com.coinbase.prime.apikey.ApiKeyRotation;
1920
import com.coinbase.prime.apikey.ApiKeyService;
2021
import com.coinbase.prime.apikey.RotateApiKeyRequest;
2122
import com.coinbase.prime.apikey.RotateApiKeyResponse;
23+
import com.coinbase.prime.apikey.RotatedApiKeyCredentials;
2224
import com.coinbase.prime.client.CoinbasePrimeClient;
2325
import com.coinbase.prime.credentials.CoinbasePrimeCredentials;
2426
import com.coinbase.prime.factory.PrimeServiceFactory;
@@ -45,6 +47,14 @@ public static void main(String[] args) {
4547

4648
System.out.println(
4749
Utils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(response));
50+
51+
String signingKey =
52+
Utils.getObjectMapper().readTree(System.getenv("COINBASE_PRIME_CREDENTIALS"))
53+
.get("signingKey")
54+
.asText();
55+
RotatedApiKeyCredentials rotated = ApiKeyRotation.decrypt(signingKey, response);
56+
System.out.println("Decrypted new access_key: " + rotated.getAccessKey());
57+
System.out.println("Store the new secret_key and passphrase securely; they are not printed.");
4858
} catch (Exception e) {
4959
e.printStackTrace();
5060
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2026-present Coinbase Global, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.coinbase.prime.apikey;
18+
19+
import static com.coinbase.core.utils.Utils.isNullOrEmpty;
20+
21+
import com.coinbase.core.errors.CoinbaseClientException;
22+
import com.coinbase.prime.utils.Utils;
23+
import java.nio.charset.StandardCharsets;
24+
import java.security.GeneralSecurityException;
25+
import java.util.Arrays;
26+
import java.util.Base64;
27+
import javax.crypto.Cipher;
28+
import javax.crypto.Mac;
29+
import javax.crypto.spec.GCMParameterSpec;
30+
import javax.crypto.spec.SecretKeySpec;
31+
32+
/**
33+
* Decrypts {@code encrypted_credentials} from {@link RotateApiKeyResponse} using only the JDK:
34+
* HKDF-SHA256 (RFC 5869) and AES-256-GCM.
35+
*
36+
* <p>Wire format after Base64 decode: {@code version(1) | salt(32) | nonce(12) |
37+
* ciphertext+tag}. Version must be {@code 0x01}. HKDF info is {@code api-key-rotation}. See <a
38+
* href="https://docs.cdp.coinbase.com/prime/rest-api/api-key-rotation">API Key Rotation</a>.
39+
*/
40+
public final class ApiKeyRotation {
41+
static final byte[] HKDF_INFO = "api-key-rotation".getBytes(StandardCharsets.UTF_8);
42+
static final int VERSION = 1;
43+
static final int VERSION_LEN = 1;
44+
static final int SALT_LEN = 32;
45+
static final int NONCE_LEN = 12;
46+
static final int GCM_TAG_LEN = 16;
47+
static final int AES_KEY_LEN = 32;
48+
static final int MIN_WIRE_LEN = VERSION_LEN + SALT_LEN + NONCE_LEN + GCM_TAG_LEN;
49+
private static final int HASH_LEN = 32;
50+
51+
private ApiKeyRotation() {}
52+
53+
/**
54+
* Decrypts rotated credentials from a rotate-API-key response.
55+
*
56+
* @param secretKey current API signing key ({@code secret_key} / {@code signingKey})
57+
* @param response rotate API key response containing {@code encrypted_credentials}
58+
*/
59+
public static RotatedApiKeyCredentials decrypt(String secretKey, RotateApiKeyResponse response)
60+
throws CoinbaseClientException {
61+
if (response == null) {
62+
throw new CoinbaseClientException("Rotate API key response is required");
63+
}
64+
return decrypt(secretKey, response.getEncryptedCredentials());
65+
}
66+
67+
/**
68+
* Decrypts a Base64 {@code encrypted_credentials} payload.
69+
*
70+
* @param secretKey current API signing key ({@code secret_key} / {@code signingKey})
71+
* @param encryptedCredentials Base64-encoded wire payload
72+
*/
73+
public static RotatedApiKeyCredentials decrypt(String secretKey, String encryptedCredentials)
74+
throws CoinbaseClientException {
75+
if (isNullOrEmpty(secretKey)) {
76+
throw new CoinbaseClientException("Secret key is required to decrypt rotated credentials");
77+
}
78+
if (isNullOrEmpty(encryptedCredentials)) {
79+
throw new CoinbaseClientException("encrypted_credentials is required");
80+
}
81+
82+
byte[] raw = decodeBase64PadTolerant(encryptedCredentials);
83+
if (raw.length < MIN_WIRE_LEN) {
84+
throw new CoinbaseClientException("encrypted_credentials payload is too short");
85+
}
86+
if ((raw[0] & 0xFF) != VERSION) {
87+
throw new CoinbaseClientException(
88+
"unsupported encrypted_credentials wire version: " + (raw[0] & 0xFF));
89+
}
90+
91+
byte[] salt = Arrays.copyOfRange(raw, VERSION_LEN, VERSION_LEN + SALT_LEN);
92+
byte[] nonce =
93+
Arrays.copyOfRange(raw, VERSION_LEN + SALT_LEN, VERSION_LEN + SALT_LEN + NONCE_LEN);
94+
byte[] ciphertext = Arrays.copyOfRange(raw, VERSION_LEN + SALT_LEN + NONCE_LEN, raw.length);
95+
96+
byte[] ikm = secretKey.getBytes(StandardCharsets.UTF_8);
97+
byte[] aesKey = null;
98+
try {
99+
aesKey = hkdfSha256(ikm, salt, HKDF_INFO, AES_KEY_LEN);
100+
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
101+
cipher.init(
102+
Cipher.DECRYPT_MODE,
103+
new SecretKeySpec(aesKey, "AES"),
104+
new GCMParameterSpec(GCM_TAG_LEN * 8, nonce));
105+
byte[] plaintext = cipher.doFinal(ciphertext);
106+
return Utils.getObjectMapper().readValue(plaintext, RotatedApiKeyCredentials.class);
107+
} catch (CoinbaseClientException e) {
108+
throw e;
109+
} catch (Exception e) {
110+
throw new CoinbaseClientException("Failed to decrypt rotated API key credentials", e);
111+
} finally {
112+
Arrays.fill(ikm, (byte) 0);
113+
if (aesKey != null) {
114+
Arrays.fill(aesKey, (byte) 0);
115+
}
116+
}
117+
}
118+
119+
/**
120+
* HKDF-SHA256 (RFC 5869) extract-then-expand. Package-visible for RFC test vectors.
121+
*/
122+
static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length)
123+
throws GeneralSecurityException {
124+
if (length <= 0 || length > 255 * HASH_LEN) {
125+
throw new IllegalArgumentException("invalid HKDF output length");
126+
}
127+
byte[] prk = hmacSha256(salt != null ? salt : new byte[HASH_LEN], ikm);
128+
try {
129+
return hkdfExpand(prk, info, length);
130+
} finally {
131+
Arrays.fill(prk, (byte) 0);
132+
}
133+
}
134+
135+
private static byte[] hkdfExpand(byte[] prk, byte[] info, int length)
136+
throws GeneralSecurityException {
137+
byte[] infoBytes = info != null ? info : new byte[0];
138+
int n = (length + HASH_LEN - 1) / HASH_LEN;
139+
byte[] okm = new byte[length];
140+
byte[] t = new byte[0];
141+
int offset = 0;
142+
for (int i = 1; i <= n; i++) {
143+
byte[] input = new byte[t.length + infoBytes.length + 1];
144+
System.arraycopy(t, 0, input, 0, t.length);
145+
System.arraycopy(infoBytes, 0, input, t.length, infoBytes.length);
146+
input[input.length - 1] = (byte) i;
147+
t = hmacSha256(prk, input);
148+
int toCopy = Math.min(HASH_LEN, length - offset);
149+
System.arraycopy(t, 0, okm, offset, toCopy);
150+
offset += toCopy;
151+
}
152+
return okm;
153+
}
154+
155+
private static byte[] hmacSha256(byte[] key, byte[] data) throws GeneralSecurityException {
156+
Mac mac = Mac.getInstance("HmacSHA256");
157+
mac.init(new SecretKeySpec(key, "HmacSHA256"));
158+
return mac.doFinal(data);
159+
}
160+
161+
static byte[] decodeBase64PadTolerant(String encoded) {
162+
int mod = encoded.length() % 4;
163+
String padded = mod == 0 ? encoded : encoded + "====".substring(mod);
164+
return Base64.getDecoder().decode(padded);
165+
}
166+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2026-present Coinbase Global, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.coinbase.prime.apikey;
18+
19+
import com.coinbase.core.errors.CoinbaseClientException;
20+
import com.coinbase.core.utils.Utils;
21+
import com.coinbase.prime.credentials.CoinbasePrimeCredentials;
22+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
23+
import com.fasterxml.jackson.annotation.JsonProperty;
24+
25+
/** Plaintext credentials returned after decrypting a rotate-API-key payload. */
26+
@JsonIgnoreProperties(ignoreUnknown = true)
27+
public class RotatedApiKeyCredentials {
28+
@JsonProperty("access_key")
29+
private String accessKey;
30+
31+
@JsonProperty("secret_key")
32+
private String secretKey;
33+
34+
@JsonProperty("passphrase")
35+
private String passphrase;
36+
37+
@JsonProperty("service_account_id")
38+
private String serviceAccountId;
39+
40+
public RotatedApiKeyCredentials() {}
41+
42+
public String getAccessKey() {
43+
return accessKey;
44+
}
45+
46+
public void setAccessKey(String accessKey) {
47+
this.accessKey = accessKey;
48+
}
49+
50+
public String getSecretKey() {
51+
return secretKey;
52+
}
53+
54+
public void setSecretKey(String secretKey) {
55+
this.secretKey = secretKey;
56+
}
57+
58+
public String getPassphrase() {
59+
return passphrase;
60+
}
61+
62+
public void setPassphrase(String passphrase) {
63+
this.passphrase = passphrase;
64+
}
65+
66+
public String getServiceAccountId() {
67+
return serviceAccountId;
68+
}
69+
70+
public void setServiceAccountId(String serviceAccountId) {
71+
this.serviceAccountId = serviceAccountId;
72+
}
73+
74+
/**
75+
* Builds SDK credentials from the rotated key. {@code secret_key} maps to {@code signingKey}.
76+
*
77+
* @throws CoinbaseClientException if required fields are missing
78+
*/
79+
public CoinbasePrimeCredentials toPrimeCredentials() throws CoinbaseClientException {
80+
if (Utils.isNullOrEmpty(serviceAccountId)) {
81+
return new CoinbasePrimeCredentials(accessKey, passphrase, secretKey);
82+
}
83+
return new CoinbasePrimeCredentials(accessKey, passphrase, secretKey, serviceAccountId);
84+
}
85+
}

0 commit comments

Comments
 (0)