@coinbase/cdp-sdk
Version:
SDK for interacting with the Coinbase Developer Platform Wallet API
359 lines (319 loc) • 11 kB
text/typescript
/**
* @module JWT
*/
import { SignJWT, importPKCS8, importJWK, JWTPayload } from "jose";
import { getRandomValues } from "uncrypto";
import { authHash } from "./hash.js";
import { UserInputValidationError } from "../../errors.js";
import { sortKeys } from "../../utils/sortKeys.js";
import { InvalidWalletSecretFormatError, UndefinedWalletSecretError } from "../errors.js";
/**
* JwtOptions contains configuration for JWT generation.
*
* This interface holds all necessary parameters for generating a JWT token
* for authenticating with Coinbase's REST APIs. It supports both EC (ES256)
* and Ed25519 (EdDSA) keys.
*/
export interface JwtOptions {
/**
* The API key ID
*
* Examples:
* 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
* 'organizations/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/apiKeys/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
*/
apiKeyId: string;
/**
* The API key secret
*
* Examples:
* 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx==' (Edwards key (Ed25519))
* '-----BEGIN EC PRIVATE KEY-----\n...\n...\n...==\n-----END EC PRIVATE KEY-----\n' (EC key (ES256))
*/
apiKeySecret: string;
/**
* The HTTP method for the request (e.g. 'GET', 'POST'), or null for JWTs intended for websocket connections
*/
requestMethod?: string | null;
/**
* The host for the request (e.g. 'api.cdp.coinbase.com'), or null for JWTs intended for websocket connections
*/
requestHost?: string | null;
/**
* The path for the request (e.g. '/platform/v1/wallets'), or null for JWTs intended for websocket connections
*/
requestPath?: string | null;
/**
* Optional expiration time in seconds (defaults to 120)
*/
expiresIn?: number;
/**
* Optional audience claim for the JWT
*/
audience?: string[];
}
/**
* WalletJwtOptions contains configuration for Wallet Auth JWT generation.
*
* This interface holds all necessary parameters for generating a Wallet Auth JWT
* for authenticating with endpoints that require wallet authentication.
*/
export interface WalletJwtOptions {
/**
* - The Wallet Secret
*/
walletSecret: string;
/**
* - The HTTP method for the request (e.g. 'GET', 'POST')
*/
requestMethod: string;
/**
* - The host for the request (e.g. 'api.cdp.coinbase.com')
*/
requestHost: string;
/**
* - The path for the request (e.g. '/platform/v1/wallets/{wallet_id}/addresses')
*/
requestPath: string;
/**
* - The request data for the request (e.g. `{ "wallet_id": "1234567890" }`)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
requestData: Record<string, any>;
}
/**
* Generates a JWT (also known as a Bearer token) for authenticating with Coinbase's REST APIs.
* Supports both EC (ES256) and Ed25519 (EdDSA) keys. Also supports JWTs meant for
* websocket connections by allowing requestMethod, requestHost, and requestPath to all be
* null, in which case the 'uris' claim is omitted from the JWT.
*
* @param options - The configuration options for generating the JWT
* @returns The generated JWT (Bearer token) string
* @throws {Error} If required parameters are missing, invalid, or if JWT signing fails
*/
export async function generateJwt(options: JwtOptions): Promise<string> {
// Validate required parameters
if (!options.apiKeyId) {
throw new Error("Key name is required");
}
if (!options.apiKeySecret) {
throw new Error("Private key is required");
}
// Check if we have a REST API request or a websocket connection
const hasAllRequestParams = Boolean(
options.requestMethod && options.requestHost && options.requestPath,
);
const hasNoRequestParams =
(options.requestMethod === undefined || options.requestMethod === null) &&
(options.requestHost === undefined || options.requestHost === null) &&
(options.requestPath === undefined || options.requestPath === null);
// Ensure we either have all request parameters or none (for websocket)
if (!hasAllRequestParams && !hasNoRequestParams) {
throw new Error(
"Either all request details (method, host, path) must be provided, or all must be null for JWTs intended for websocket connections",
);
}
const now = Math.floor(Date.now() / 1000);
const expiresIn = options.expiresIn || 120; // Default to 120 seconds if not specified
// Prepare the JWT payload
const claims: JWTPayload = {
sub: options.apiKeyId,
iss: "cdp",
aud: options.audience,
};
// Add the uris claim only for REST API requests
if (hasAllRequestParams) {
claims.uris = [`${options.requestMethod} ${options.requestHost}${options.requestPath}`];
}
// Generate random nonce for the header
const randomNonce = nonce();
// Determine if we're using EC or Edwards key based on the key format
if (await isValidECKey(options.apiKeySecret)) {
return await buildECJWT(
options.apiKeySecret,
options.apiKeyId,
claims,
now,
expiresIn,
randomNonce,
);
} else if (isValidEd25519Key(options.apiKeySecret)) {
return await buildEdwardsJWT(
options.apiKeySecret,
options.apiKeyId,
claims,
now,
expiresIn,
randomNonce,
);
} else {
throw new UserInputValidationError(
"Invalid key format - must be either PEM EC key or base64 Ed25519 key",
);
}
}
/**
* Generates a wallet authentication JWT for the given API endpoint URL.
* Used for authenticating with specific endpoints that require wallet authentication.
*
* @param options - The configuration options for generating the JWT
* @returns The generated JWT (Bearer token) string
* @throws {UndefinedWalletSecretError} If the Wallet Secret is not defined.
* @throws {InvalidWalletSecretFormatError} If the private key is not in the correct format or signing fails.
*/
export async function generateWalletJwt(options: WalletJwtOptions): Promise<string> {
if (!options.walletSecret) {
throw new UndefinedWalletSecretError("Wallet Secret is not defined");
}
const uri = `${options.requestMethod} ${options.requestHost}${options.requestPath}`;
const now = Math.floor(Date.now() / 1000);
const claims: JWTPayload = {
uris: [uri],
};
const shouldIncludeReqHash =
typeof options.requestData === "object" &&
Object.keys(options.requestData).length > 0 &&
Object.values(options.requestData).some(value => value !== undefined);
if (shouldIncludeReqHash) {
const sortedData = sortKeys(options.requestData);
claims.reqHash = await authHash(Buffer.from(JSON.stringify(sortedData)));
}
try {
// Convert base64 DER to PEM format for jose
const derBuffer = Buffer.from(options.walletSecret, "base64");
const pemKey = `-----BEGIN PRIVATE KEY-----\n${derBuffer
.toString("base64")
.match(/.{1,64}/g)
?.join("\n")}\n-----END PRIVATE KEY-----`;
const ecKey = await importPKCS8(pemKey, "ES256");
return await new SignJWT(claims)
.setProtectedHeader({ alg: "ES256", typ: "JWT" })
.setIssuedAt(now)
.setNotBefore(now)
.setJti(nonce())
.sign(ecKey);
} catch (error) {
throw new InvalidWalletSecretFormatError("Could not create the EC key: " + error);
}
}
/**
* Determines if a string could be a valid Ed25519 key
*
* @param str - The string to test
* @returns True if the string could be a valid Ed25519 key, false otherwise
*/
function isValidEd25519Key(str: string): boolean {
try {
const decoded = Buffer.from(str, "base64");
return decoded.length === 64;
} catch {
return false;
}
}
/**
* Determines if a string is a valid EC private key in PEM format
*
* @param str - The string to test
* @returns True if the string is a valid EC private key in PEM format
*/
async function isValidECKey(str: string): Promise<boolean> {
try {
// Try to import the key with jose - if it works, it's a valid EC key
await importPKCS8(str, "ES256");
return true;
} catch {
return false;
}
}
/**
* Builds a JWT using an EC key.
*
* @param privateKey - The EC private key in PEM format
* @param keyName - The key name/ID
* @param claims - The JWT claims
* @param now - Current timestamp in seconds
* @param expiresIn - Number of seconds until the token expires
* @param nonce - Random nonce for the JWT header
* @returns A JWT token signed with an EC key
* @throws {Error} If key conversion, import, or signing fails
*/
async function buildECJWT(
privateKey: string,
keyName: string,
claims: JWTPayload,
now: number,
expiresIn: number,
nonce: string,
): Promise<string> {
try {
// Import the key directly with jose
const ecKey = await importPKCS8(privateKey, "ES256");
// Sign and return the JWT
return await new SignJWT(claims)
.setProtectedHeader({ alg: "ES256", kid: keyName, typ: "JWT", nonce })
.setIssuedAt(Math.floor(now))
.setNotBefore(Math.floor(now))
.setExpirationTime(Math.floor(now + expiresIn))
.sign(ecKey);
} catch (error) {
throw new Error(`Failed to generate EC JWT: ${(error as Error).message}`);
}
}
/**
* Builds a JWT using an Ed25519 key.
*
* @param privateKey - The Ed25519 private key in base64 format
* @param keyName - The key name/ID
* @param claims - The JWT claims
* @param now - Current timestamp in seconds
* @param expiresIn - Number of seconds until the token expires
* @param nonce - Random nonce for the JWT header
* @returns A JWT token using an Ed25519 key
* @throws {Error} If key parsing, import, or signing fails
*/
async function buildEdwardsJWT(
privateKey: string,
keyName: string,
claims: JWTPayload,
now: number,
expiresIn: number,
nonce: string,
): Promise<string> {
try {
// Decode the base64 key (expecting 64 bytes: 32 for seed + 32 for public key)
const decoded = Buffer.from(privateKey, "base64");
if (decoded.length !== 64) {
throw new UserInputValidationError("Invalid Ed25519 key length");
}
const seed = decoded.subarray(0, 32);
const publicKey = decoded.subarray(32);
// Create JWK from the key components
const jwk = {
kty: "OKP",
crv: "Ed25519",
d: seed.toString("base64url"),
x: publicKey.toString("base64url"),
};
// Import the key for signing
const key = await importJWK(jwk, "EdDSA");
// Sign and return the JWT
return await new SignJWT(claims)
.setProtectedHeader({ alg: "EdDSA", kid: keyName, typ: "JWT", nonce })
.setIssuedAt(Math.floor(now))
.setNotBefore(Math.floor(now))
.setExpirationTime(Math.floor(now + expiresIn))
.sign(key);
} catch (error) {
throw new Error(`Failed to generate Ed25519 JWT: ${(error as Error).message}`);
}
}
/**
* Generates a random nonce for the JWT.
*
* @returns {string} The generated nonce.
*/
function nonce(): string {
const bytes = new Uint8Array(16);
getRandomValues(bytes);
return Buffer.from(bytes).toString("hex");
}