bug fixes in response to code review. Also added support for ed25519 coinbase cdp api keys

This commit is contained in:
Mick Grove 2025-07-31 18:29:21 -07:00
commit f0a99dcfcd
7 changed files with 151 additions and 116 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
## [1.29.0]
- Fixed issue when more than 1 named capture group is used in a rule variable
- Added 2 new liquid template filters: `b64dec` and `es256_sign`
- Added a new liquid template filters: `b64dec`
- Added custom validator for Coinbase, and a Coinbase rule that uses it
## [1.28.0]

View file

@ -191,6 +191,7 @@ walkdir = "2.5.0"
p256 = "0.13.2"
sec1 = "0.7.3"
rand_core = "0.9.3"
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
[dependencies.tikv-jemallocator]
version = "0.6"

View file

@ -30,7 +30,7 @@ rules:
- report_response: true
- type: StatusMatch
status: [200]
- name: Coinbase CDP API Key
- name: Coinbase CDP API Key (ECDSA)
id: kingfisher.coinbase.2
pattern: |
(?xims)
@ -64,4 +64,17 @@ rules:
{
"name": "organizations/243873d8-c14e-436d-9cea-10d530cbe201/apiKeys/d29bb143-ad4c-234f-9bd7-c705c16b6d19",
"privateKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDs+vr9F40Mer+qYksK3QhkSMfUXOZsbRVSrelWGnMh3oAoGCCqGSM49\nAwEHoUQDQgAEOXj2qKzLYx21D3plbOa81ilURS/4K1jzLXBvgwfUe4hWDgBdKQvq\nIiet5qqZEwVlR/LqKQEUlP8YLrjLFU8Unw==\n-----END EC PRIVATE KEY-----\n"
}
- name: Coinbase CDP API Key (Ed25519)
id: kingfisher.coinbase.3
pattern: |
(?xis)
"id"\s*:\s*"(?P<CRED_NAME>[0-9a-f-]{36})"[^{]*?"privateKey"\s*:\s*"(?P<PRIVATE_KEY>[A-Za-z0-9+/=]{88})"
validation:
type: Coinbase
examples:
- |
{
"id": "413b23bf-4582-4e57-b33a-85d9527d9972",
"privateKey": "ygWq07YCO8UkmC9BE0PDBJNGhiu80yslsMUF9WnjPaIF5DBxb/wljjRuHhfuR/AMPC+kdgtL+mWKq/HOnq/YcQ=="
}

View file

@ -295,44 +295,6 @@ impl Filter for B64DecFilter {
}
}
#[derive(Debug, FilterParameters)]
struct Es256Args {
#[parameter(description = "PEM EC private key", arg_type = "str")]
key: Expression,
}
#[derive(Clone, ParseFilter, FilterReflection, Default)]
#[filter(
name = "es256_sign",
description = "ECDSA P-256 SHA-256 signature (Base64URL)",
parameters(Es256Args),
parsed(Es256SignFilter)
)]
pub struct Es256Sign;
#[derive(Debug, FromFilterParameters, Display_filter)]
#[name = "es256_sign"]
struct Es256SignFilter {
#[parameters]
args: Es256Args,
}
impl Filter for Es256SignFilter {
fn evaluate(&self, input: &dyn ValueView, runtime: &dyn Runtime) -> Result<Value, LiquidError> {
let args = self.args.evaluate(runtime)?;
let key_pem = args.key.to_kstr();
let signing_key = SigningKey::from_sec1_pem(&key_pem)
.or_else(|_| SigningKey::from_pkcs8_pem(&key_pem))
.map_err(|e| LiquidError::with_msg(e.to_string()))?;
let sig: p256::ecdsa::Signature = signing_key.sign(input.to_kstr().as_bytes());
// turn the signature into raw bytes…
let raw = sig.to_bytes();
// …then Base64-URL encode
let b64 = general_purpose::URL_SAFE_NO_PAD.encode(raw);
Ok(Value::scalar(b64))
}
}
// -----------------------------------------------------------------------------
// Authentication & Security
@ -456,7 +418,6 @@ pub fn register_all(builder: liquid::ParserBuilder) -> liquid::ParserBuilder {
.filter(JwtHeaderFilter::default())
.filter(B64EncFilter::default())
.filter(B64DecFilter::default())
.filter(Es256Sign::default())
.filter(RandomStringFilter::default())
.filter(HmacSha256::default())
.filter(HmacSha1::default())
@ -515,18 +476,6 @@ mod tests {
assert_eq!(render(r#"{{ "data" | hmac_sha1: "key1" }}"#), expect);
}
#[test]
fn es256_sign_filter() {
let key = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIDs+vr9F40Mer+qYksK3QhkSMfUXOZsbRVSrelWGnMh3oAoGCCqGSM49\nAwEHoUQDQgAEOXj2qKzLYx21D3plbOa81ilURS/4K1jzLXBvgwfUe4hWDgBdKQvq\nIiet5qqZEwVlR/LqKQEUlP8YLrjLFU8Unw==\n-----END EC PRIVATE KEY-----";
use p256::ecdsa::{signature::Signer, SigningKey};
let sk = SigningKey::from_sec1_pem(key).unwrap();
let sig: p256::ecdsa::Signature = sk.sign(b"hello");
let expect = general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
let tmpl = format!(r#"{{ "hello" | es256_sign: "{}" }}"#, key.replace('\n', "\\n"));
assert_eq!(render(&tmpl), expect);
}
#[test]
fn b64url_enc_filter() {
assert_eq!(

View file

@ -5,27 +5,27 @@
// * Fallback - system allocator (`system-alloc` feature)
// ────────────────────────────────────────────────────────────
// // --- jemalloc (opt-in) ---
// #[cfg(feature = "use-jemalloc")]
// #[global_allocator]
// static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
// --- jemalloc (opt-in) ---
#[cfg(feature = "use-jemalloc")]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
// // --- mimalloc (default) ---
// #[cfg(all(not(feature = "use-jemalloc"), not(feature = "system-alloc")))]
// #[global_allocator]
// static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
// // --- system allocator (explicit opt-out) ---
// #[cfg(feature = "system-alloc")]
// use std::alloc::System;
// #[cfg(feature = "system-alloc")]
// #[global_allocator]
// static GLOBAL: System = System;
// --- mimalloc (default) ---
#[cfg(all(not(feature = "use-jemalloc"), not(feature = "system-alloc")))]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
// --- system allocator (explicit opt-out) ---
#[cfg(feature = "system-alloc")]
use std::alloc::System;
#[cfg(feature = "system-alloc")]
#[global_allocator]
static GLOBAL: System = System;
// use std::alloc::System;
// #[global_allocator]
// static GLOBAL: System = System;
use std::{
io::Read,
sync::{Arc, Mutex},

View file

@ -5,15 +5,15 @@ use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use p256::{
ecdsa::{signature::Signer, SigningKey},
ecdsa::{signature::Signer as _, SigningKey},
pkcs8::DecodePrivateKey,
SecretKey,
};
use rand::TryRngCore;
use ed25519_dalek::{SigningKey as Ed25519Key, Signer as _};
use rand::rngs::OsRng;
use reqwest::{Client, StatusCode, Url};
use sha1::{Digest, Sha1};
use rand::TryRngCore;
use crate::validation::{httpvalidation, Cache, CachedResponse, VALIDATION_CACHE_SECONDS};
@ -61,53 +61,139 @@ pub async fn validate_cdp_api_key(
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let ok = status == StatusCode::OK;
let msg = format!("{body}");
let msg = body;
cache.insert(cache_key.clone(), CachedResponse::new(msg.clone(), status, ok));
Ok((ok, msg))
}
// fn build_jwt(
// method: &str,
// host: &str,
// endpoint: &str,
// cred_name: &str,
// pem: &str,
// ) -> Result<String> {
// let pem =
// pem.replace("\r\n", "\n").replace("\\r\\n", "\n").replace("\\n", "\n").replace("\r", "\n");
// let secret_key = SecretKey::from_sec1_pem(&pem)
// .or_else(|_| SecretKey::from_pkcs8_pem(&pem))
// .map_err(|e| anyhow!("invalid EC key: {e}"))?;
// let signing_key = SigningKey::from(secret_key);
// let mut rng = OsRng;
// let mut nonce = [0u8; 16];
// let _ = rng.try_fill_bytes(&mut nonce);
// let header = serde_json::json!({
// "typ": "JWT",
// "alg": "ES256",
// "kid": cred_name,
// "nonce": hex::encode(nonce),
// });
// let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
// let now = Utc::now().timestamp();
// let claims = serde_json::json!({
// "sub": cred_name,
// "iss": "cdp",
// "nbf": now,
// "exp": now + 60,
// "uri": format!("{} {}{}", method, host, endpoint),
// });
// let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
// let signing_input = format!("{header_b64}.{claims_b64}");
// let sig: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
// let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
// Ok(format!("{signing_input}.{sig_b64}"))
// }
fn build_jwt(
method: &str,
host: &str,
endpoint: &str,
cred_name: &str,
key_name: &str,
pem: &str,
) -> Result<String> {
let pem =
pem.replace("\r\n", "\n").replace("\\r\\n", "\n").replace("\\n", "\n").replace("\r", "\n");
let secret_key = SecretKey::from_sec1_pem(&pem)
.or_else(|_| SecretKey::from_pkcs8_pem(&pem))
.map_err(|e| anyhow!("invalid EC key: {e}"))?;
let signing_key = SigningKey::from(secret_key);
let mut rng = OsRng;
let mut nonce = [0u8; 16];
let _ = rng.try_fill_bytes(&mut nonce);
let header = serde_json::json!({
"typ": "JWT",
"alg": "ES256",
"kid": cred_name,
"nonce": hex::encode(nonce),
});
let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
// Try ECDSA (PEM encoded EC key). Fallback to raw Ed25519 base64 key.
if let Ok(secret_key) = SecretKey::from_sec1_pem(&pem)
.or_else(|_| SecretKey::from_pkcs8_pem(&pem))
{
let signing_key = SigningKey::from(secret_key);
let header = serde_json::json!({
"typ": "JWT",
"alg": "ES256",
"kid": key_name,
"nonce": hex::encode(nonce),
});
let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
let now = Utc::now().timestamp();
let claims = serde_json::json!({
"sub": cred_name,
"iss": "cdp",
"nbf": now,
"exp": now + 60,
"uri": format!("{} {}{}", method, host, endpoint),
});
let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let now = Utc::now().timestamp();
let claims = serde_json::json!({
"sub": key_name,
"iss": "cdp",
"nbf": now,
"exp": now + 120,
"uri": format!("{} {}{}", method, host, endpoint),
});
let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let signing_input = format!("{header_b64}.{claims_b64}");
let sig: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
let signing_input = format!("{header_b64}.{claims_b64}");
let sig: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
Ok(format!("{signing_input}.{sig_b64}"))
}
return Ok(format!("{signing_input}.{sig_b64}"));
} else {
// Assume base64-encoded Ed25519 keypair
let key_bytes = base64::engine::general_purpose::STANDARD
.decode(pem.as_bytes())
.map_err(|e| anyhow!("invalid base64 key: {e}"))?;
let signing_key = match key_bytes.len() {
32 => {
let arr: [u8; 32] = key_bytes[..32].try_into().unwrap();
Ed25519Key::from_bytes(&arr)
}
64 => {
let arr: [u8; 64] = key_bytes[..64].try_into().unwrap();
Ed25519Key::from_keypair_bytes(&arr).map_err(|e| anyhow!("invalid Ed25519 key: {e}"))?
}
_ => return Err(anyhow!("invalid Ed25519 key length")),
};
let header = serde_json::json!({
"typ": "JWT",
"alg": "EdDSA",
"kid": key_name,
"nonce": hex::encode(nonce),
});
let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
let now = Utc::now().timestamp();
let claims = serde_json::json!({
"sub": key_name,
"iss": "cdp",
"nbf": now,
"exp": now + 120,
"uri": format!("{} {}{}", method, host, endpoint),
});
let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let signing_input = format!("{header_b64}.{claims_b64}");
let sig: ed25519_dalek::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes());
return Ok(format!("{signing_input}.{sig_b64}"));
}
}

View file

@ -3,20 +3,6 @@ use tokio::net::lookup_host;
use crate::validation::SerializableCaptures;
// pub fn process_captures(captures: &SerializableCaptures) -> Vec<(String, String, usize, usize)> {
// let has_multiple_captures = captures.captures.len() > 1;
// captures
// .captures
// .iter()
// .enumerate()
// .filter(|(idx, _)| !has_multiple_captures || *idx > 0)
// .map(|(_, capture)| {
// let name = capture.name.as_ref().map_or("TOKEN".to_string(), |n| n.to_uppercase());
// (name, capture.value.clone().into_owned(), capture.start, capture.end)
// })
// .collect()
// }
/// Return (NAME, value, start, end) for every capture we care about.
///
/// * If a capture has a name, use that (upper-cased)
@ -31,7 +17,7 @@ pub fn process_captures(
captures
.captures
.iter()
.filter(|cap| multiple.then(|| cap.name.is_some()).unwrap_or(true))
.filter(|cap| !multiple || cap.name.is_some())
.map(|cap| {
let name = cap
.name