From b78509c18e64003db61cac82519f20602f7a3634 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:22:47 +0100 Subject: [PATCH 1/3] feat: add API key and rate limit types to shared types --- contracts/types/src/lib.rs | 147 ++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..c83fefa 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttype, Address, String, Vec}; +use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; /// Billing interval in seconds. #[contracttype] @@ -360,4 +360,149 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Added in storage version 5 (API Key & Rate Limiting) ── + ApiKey(u64), + ApiKeyCount, + ApiKeysByOwner(Address), + ApiKeyAudit(u64), + ApiKeyAuditCount, + RateLimitMinute(u64, u64), + RateLimitHour(u64, u64), + RateLimitDay(u64, u64), + ApiUsage(u64, u64), +} + +pub type ApiKeyId = u64; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ApiKeyStatus { + Active, + Revoked, + Expired, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum UsageTier { + Free, + Basic, + Pro, + Enterprise, +} + +impl UsageTier { + pub fn default_rate_limit(&self) -> RateLimitConfig { + match self { + UsageTier::Free => RateLimitConfig { + requests_per_minute: 100, + requests_per_hour: 1_000, + requests_per_day: 10_000, + burst_limit: 10, + }, + UsageTier::Basic => RateLimitConfig { + requests_per_minute: 1_000, + requests_per_hour: 10_000, + requests_per_day: 100_000, + burst_limit: 50, + }, + UsageTier::Pro => RateLimitConfig { + requests_per_minute: 10_000, + requests_per_hour: 100_000, + requests_per_day: 1_000_000, + burst_limit: 200, + }, + UsageTier::Enterprise => RateLimitConfig { + requests_per_minute: 100_000, + requests_per_hour: 1_000_000, + requests_per_day: 10_000_000, + burst_limit: 1000, + }, + } + } + + pub fn price_per_thousand(&self) -> i128 { + match self { + UsageTier::Free => 0, + UsageTier::Basic => 1, // 0.001 per 1k requests (in stroops) + UsageTier::Pro => 5, // 0.005 per 1k + UsageTier::Enterprise => 10, // 0.01 per 1k + } + } +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitConfig { + pub requests_per_minute: u32, + pub requests_per_hour: u32, + pub requests_per_day: u32, + pub burst_limit: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKeyConfig { + pub name: String, + pub rate_limit: RateLimitConfig, + pub usage_tier: UsageTier, + pub expires_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKey { + pub id: ApiKeyId, + pub owner: Address, + pub key_hash: BytesN<32>, + pub name: String, + pub rate_limit: RateLimitConfig, + pub usage_tier: UsageTier, + pub status: ApiKeyStatus, + pub created_at: u64, + pub expires_at: u64, + pub last_used_at: u64, + pub revoked_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitWindow { + pub window_start: u64, + pub count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiUsageRecord { + pub window_start: u64, + pub count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RateLimitStatus { + pub is_allowed: bool, + pub remaining: u32, + pub reset_at: u64, + pub retry_after: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct UsageReport { + pub key_id: ApiKeyId, + pub period: TimeRange, + pub total_requests: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApiKeyAuditEntry { + pub id: u64, + pub key_id: ApiKeyId, + pub action: String, + pub changed_by: Address, + pub timestamp: u64, } From 306ca22ac8ec21a76723be8c0eca081b988a9b1d Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:23:00 +0100 Subject: [PATCH 2/3] feat: add API key management and rate limiting contract --- contracts/Cargo.toml | 1 + contracts/api/Cargo.toml | 16 ++ contracts/api/src/auth.rs | 225 +++++++++++++++++++++ contracts/api/src/lib.rs | 118 +++++++++++ contracts/api/src/ratelimit.rs | 126 ++++++++++++ contracts/api/src/test.rs | 353 +++++++++++++++++++++++++++++++++ 6 files changed, 839 insertions(+) create mode 100644 contracts/api/Cargo.toml create mode 100644 contracts/api/src/auth.rs create mode 100644 contracts/api/src/lib.rs create mode 100644 contracts/api/src/ratelimit.rs create mode 100644 contracts/api/src/test.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 77896f2..2680225 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "2" members = [ + "api", "proxy", "storage", "subscription", diff --git a/contracts/api/Cargo.toml b/contracts/api/Cargo.toml new file mode 100644 index 0000000..5167e33 --- /dev/null +++ b/contracts/api/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "subtrackr-api" +version = "0.1.0" +edition = "2021" +authors = ["SubTrackr Team"] +description = "SubTrackr API key management and rate limiting contract (Soroban)" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "21.0.0" +subtrackr-types = { path = "../types" } + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/api/src/auth.rs b/contracts/api/src/auth.rs new file mode 100644 index 0000000..d47304e --- /dev/null +++ b/contracts/api/src/auth.rs @@ -0,0 +1,225 @@ +use soroban_sdk::{Address, Bytes, BytesN, Env, String, Vec}; +use subtrackr_types::{ApiKey, ApiKeyAuditEntry, ApiKeyConfig, ApiKeyId, ApiKeyStatus}; + +use crate::DataKey; + +const MAX_KEYS_PER_OWNER: u32 = 10; + +pub fn generate_key_id(env: &Env) -> ApiKeyId { + let mut count: ApiKeyId = env + .storage() + .instance() + .get(&DataKey::ApiKeyCount) + .unwrap_or(0); + count += 1; + env.storage().instance().set(&DataKey::ApiKeyCount, &count); + count +} + +fn hash_key_bytes(env: &Env, raw: &Bytes) -> BytesN<32> { + env.crypto().sha256(raw).into() +} + +fn generate_raw_key_bytes(env: &Env) -> Bytes { + let mut raw = Bytes::new(env); + raw.push_back(0x73); // 's' + raw.push_back(0x6b); // 'k' + raw.push_back(0x5f); // '_' + for _ in 0..32 { + let byte: u8 = (env.prng().gen::() % 256) as u8; + raw.push_back(byte); + } + raw +} + +pub fn create_api_key( + env: &Env, + owner: Address, + config: ApiKeyConfig, + now: u64, +) -> (ApiKeyId, Bytes) { + let key_id = generate_key_id(env); + + let existing: Option> = env.storage().instance().get(&DataKey::KeysByOwner); + let mut owners: Vec
= existing.unwrap_or(Vec::new(env)); + let mut owner_found = false; + for o in owners.iter() { + if o == owner { + owner_found = true; + break; + } + } + if !owner_found { + owners.push_back(owner.clone()); + env.storage().instance().set(&DataKey::KeysByOwner, &owners); + } + + let owner_keys: Vec = env + .storage() + .instance() + .get(&DataKey::OwnerKeys(owner.clone())) + .unwrap_or(Vec::new(env)); + assert!( + (owner_keys.len() as u32) < MAX_KEYS_PER_OWNER, + "Max keys per owner reached" + ); + let mut new_owner_keys = owner_keys; + new_owner_keys.push_back(key_id); + env.storage() + .instance() + .set(&DataKey::OwnerKeys(owner.clone()), &new_owner_keys); + + let raw_key = generate_raw_key_bytes(env); + let key_hash = hash_key_bytes(env, &raw_key); + + let api_key = ApiKey { + id: key_id, + owner: owner.clone(), + key_hash, + name: config.name, + rate_limit: config.rate_limit, + usage_tier: config.usage_tier, + status: ApiKeyStatus::Active, + created_at: now, + expires_at: config.expires_at, + last_used_at: 0, + revoked_at: 0, + }; + env.storage() + .instance() + .set(&DataKey::ApiKey(key_id), &api_key); + + (key_id, raw_key) +} + +pub fn get_api_key(env: &Env, key_id: ApiKeyId) -> Option { + env.storage().instance().get(&DataKey::ApiKey(key_id)) +} + +pub fn revoke_api_key(env: &Env, caller: Address, key_id: ApiKeyId, now: u64) { + let mut key: ApiKey = env + .storage() + .instance() + .get(&DataKey::ApiKey(key_id)) + .expect("ApiKey not found"); + assert!(key.owner == caller, "Only owner can revoke"); + assert!(key.status == ApiKeyStatus::Active, "Key is not active"); + + key.status = ApiKeyStatus::Revoked; + key.revoked_at = now; + env.storage() + .instance() + .set(&DataKey::ApiKey(key_id), &key); + + log_audit(env, key_id, String::from_str(env, "revoked"), caller, now); +} + +pub fn rotate_api_key( + env: &Env, + caller: Address, + key_id: ApiKeyId, + now: u64, +) -> Bytes { + let mut key: ApiKey = env + .storage() + .instance() + .get(&DataKey::ApiKey(key_id)) + .expect("ApiKey not found"); + assert!(key.owner == caller, "Only owner can rotate"); + assert!( + key.status == ApiKeyStatus::Active, + "Cannot rotate revoked key" + ); + + let new_raw = generate_raw_key_bytes(env); + let new_hash = hash_key_bytes(env, &new_raw); + key.key_hash = new_hash; + key.last_used_at = 0; + env.storage() + .instance() + .set(&DataKey::ApiKey(key_id), &key); + + log_audit(env, key_id, String::from_str(env, "rotated"), caller, now); + new_raw +} + +pub fn validate_api_key(env: &Env, key_id: ApiKeyId, key_hash: BytesN<32>, now: u64) -> bool { + let key: Option = env.storage().instance().get(&DataKey::ApiKey(key_id)); + match key { + None => false, + Some(k) => { + if k.key_hash != key_hash { + return false; + } + if k.status == ApiKeyStatus::Revoked { + return false; + } + if k.expires_at != 0 && now > k.expires_at { + return false; + } + true + } + } +} + +pub fn list_api_keys_by_owner(env: &Env, owner: Address) -> Vec { + let key_ids: Vec = env + .storage() + .instance() + .get(&DataKey::OwnerKeys(owner)) + .unwrap_or(Vec::new(env)); + let mut keys: Vec = Vec::new(env); + for id in key_ids.iter() { + if let Some(k) = get_api_key(env, id) { + keys.push_back(k); + } + } + keys +} + +pub fn get_api_key_audit(env: &Env, key_id: ApiKeyId) -> Vec { + let count: u64 = env + .storage() + .instance() + .get(&DataKey::ApiKeyAuditCount) + .unwrap_or(0); + let mut entries: Vec = Vec::new(env); + for i in 1..=count { + let entry: Option = + env.storage().instance().get(&DataKey::ApiKeyAuditEntry(i)); + if let Some(e) = entry { + if e.key_id == key_id { + entries.push_back(e); + } + } + } + entries +} + +fn log_audit( + env: &Env, + key_id: ApiKeyId, + action: String, + changed_by: Address, + now: u64, +) { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ApiKeyAuditCount) + .unwrap_or(0); + count += 1; + let entry = ApiKeyAuditEntry { + id: count, + key_id, + action, + changed_by, + timestamp: now, + }; + env.storage() + .instance() + .set(&DataKey::ApiKeyAuditEntry(count), &entry); + env.storage() + .instance() + .set(&DataKey::ApiKeyAuditCount, &count); +} diff --git a/contracts/api/src/lib.rs b/contracts/api/src/lib.rs new file mode 100644 index 0000000..023caa4 --- /dev/null +++ b/contracts/api/src/lib.rs @@ -0,0 +1,118 @@ +#![no_std] + +mod auth; +mod ratelimit; +#[cfg(test)] +mod test; + +use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes, BytesN, Env, Vec}; +use subtrackr_types::{ + ApiKey, ApiKeyAuditEntry, ApiKeyConfig, ApiKeyId, RateLimitStatus, TimeRange, UsageReport, +}; + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + ApiKey(u64), + ApiKeyCount, + OwnerKeys(Address), + KeysByOwner, + ApiKeyAuditEntry(u64), + ApiKeyAuditCount, + RateLimitMinute(u64, u64), + RateLimitHour(u64, u64), + RateLimitDay(u64, u64), +} + +#[contract] +pub struct SubTrackrApi; + +#[contractimpl] +impl SubTrackrApi { + // ── API Key Lifecycle ── + + /// Create a new API key. Returns `(key_id, raw_key_bytes)`. + /// The raw key is returned exactly once and must be stored off-chain. + pub fn create_api_key( + env: Env, + owner: Address, + config: ApiKeyConfig, + ) -> (ApiKeyId, Bytes) { + owner.require_auth(); + let now = env.ledger().timestamp(); + auth::create_api_key(&env, owner, config, now) + } + + /// Revoke an API key by id. Only the owner can revoke. + pub fn revoke_api_key(env: Env, caller: Address, key_id: ApiKeyId) { + caller.require_auth(); + let now = env.ledger().timestamp(); + auth::revoke_api_key(&env, caller, key_id, now); + } + + /// Rotate an API key. Returns new raw key bytes. + pub fn rotate_api_key(env: Env, caller: Address, key_id: ApiKeyId) -> Bytes { + caller.require_auth(); + let now = env.ledger().timestamp(); + auth::rotate_api_key(&env, caller, key_id, now) + } + + /// Validate an API key by comparing the provided key_hash against + /// the stored hash. Also checks revocation and expiry. + /// Updates `last_used_at` if valid. + pub fn validate_api_key(env: Env, key_id: ApiKeyId, key_hash: BytesN<32>) -> bool { + let now = env.ledger().timestamp(); + let valid = auth::validate_api_key(&env, key_id, key_hash, now); + if valid { + if let Some(mut key) = auth::get_api_key(&env, key_id) { + key.last_used_at = now; + env.storage().instance().set(&DataKey::ApiKey(key_id), &key); + } + } + valid + } + + /// Get details for a single API key. + pub fn get_api_key(env: Env, key_id: ApiKeyId) -> Option { + auth::get_api_key(&env, key_id) + } + + /// List all API keys owned by an address. + pub fn list_api_keys(env: Env, owner: Address) -> Vec { + auth::list_api_keys_by_owner(&env, owner) + } + + /// Get audit trail for an API key. + pub fn get_api_key_audit(env: Env, key_id: ApiKeyId) -> Vec { + auth::get_api_key_audit(&env, key_id) + } + + // ── Rate Limiting ── + + /// Check whether a request should be rate-limited for the given key. + /// Increments the request counter atomically. + pub fn check_rate_limit(env: Env, key_id: ApiKeyId, key_hash: BytesN<32>) -> RateLimitStatus { + let now = env.ledger().timestamp(); + if !auth::validate_api_key(&env, key_id, key_hash, now) { + return RateLimitStatus { + is_allowed: false, + remaining: 0, + reset_at: 0, + retry_after: 0, + }; + } + let key = auth::get_api_key(&env, key_id).expect("Key exists after validation"); + ratelimit::check_rate_limit(&env, &key, now) + } + + /// Get usage report for a key over a time period. + pub fn get_api_usage(env: Env, key_id: ApiKeyId, period: TimeRange) -> UsageReport { + ratelimit::get_api_usage(&env, key_id, period) + } + + /// Calculate usage-based charges for a key over a period. + pub fn calculate_api_charge(env: Env, key_id: ApiKeyId, period: TimeRange) -> i128 { + let key = auth::get_api_key(&env, key_id).expect("ApiKey not found"); + ratelimit::calculate_api_charge(&env, &key, period) + } +} diff --git a/contracts/api/src/ratelimit.rs b/contracts/api/src/ratelimit.rs new file mode 100644 index 0000000..bdf599e --- /dev/null +++ b/contracts/api/src/ratelimit.rs @@ -0,0 +1,126 @@ +use soroban_sdk::Env; +use subtrackr_types::{ + ApiKey, ApiKeyId, ApiUsageRecord, RateLimitStatus, TimeRange, UsageReport, +}; + +use crate::DataKey; + +const SECS_PER_MINUTE: u64 = 60; +const SECS_PER_HOUR: u64 = 3_600; +const SECS_PER_DAY: u64 = 86_400; + +fn window_start(ts: u64, period: u64) -> u64 { + ts - (ts % period) +} + +fn bump_window(env: &Env, key: DataKey, now: u64, period: u64) -> (u32, u64) { + let ws = window_start(now, period); + let record: Option = env.storage().instance().get(&key); + let count = match record { + Some(r) if r.window_start == ws => r.count + 1, + _ => 1, + }; + env.storage() + .instance() + .set(&key, &ApiUsageRecord { window_start: ws, count }); + (count, ws + period) +} + +pub fn check_rate_limit( + env: &Env, + key: &ApiKey, + now: u64, +) -> RateLimitStatus { + let cfg = &key.rate_limit; + + let (min_count, min_reset) = bump_window( + env, + DataKey::RateLimitMinute(key.id, window_start(now, SECS_PER_MINUTE)), + now, + SECS_PER_MINUTE, + ); + + let (hour_count, hour_reset) = bump_window( + env, + DataKey::RateLimitHour(key.id, window_start(now, SECS_PER_HOUR)), + now, + SECS_PER_HOUR, + ); + + let (day_count, day_reset) = bump_window( + env, + DataKey::RateLimitDay(key.id, window_start(now, SECS_PER_DAY)), + now, + SECS_PER_DAY, + ); + + let exceeded = if min_count > cfg.requests_per_minute { + true + } else if hour_count > cfg.requests_per_hour { + true + } else if day_count > cfg.requests_per_day { + true + } else { + false + }; + + if exceeded { + let reset_at = core::cmp::min(core::cmp::min(min_reset, hour_reset), day_reset); + let retry_after = reset_at.saturating_sub(now); + RateLimitStatus { + is_allowed: false, + remaining: 0, + reset_at, + retry_after, + } + } else { + let rem_min = cfg.requests_per_minute.saturating_sub(min_count); + let rem_hour = cfg.requests_per_hour.saturating_sub(hour_count); + let rem_day = cfg.requests_per_day.saturating_sub(day_count); + let remaining = core::cmp::min(core::cmp::min(rem_min, rem_hour), rem_day); + let reset_at = core::cmp::min(core::cmp::min(min_reset, hour_reset), day_reset); + RateLimitStatus { + is_allowed: true, + remaining, + reset_at, + retry_after: 0, + } + } +} + +pub fn get_api_usage( + env: &Env, + key_id: ApiKeyId, + period: TimeRange, +) -> UsageReport { + let mut total: u32 = 0; + let mut ws = window_start(period.start, SECS_PER_MINUTE); + let end = period.end; + while ws <= end { + let rec: Option = env + .storage() + .instance() + .get(&DataKey::RateLimitMinute(key_id, ws)); + if let Some(r) = rec { + total = total.saturating_add(r.count); + } + ws += SECS_PER_MINUTE; + } + + UsageReport { + key_id, + period, + total_requests: total, + } +} + +pub fn calculate_api_charge( + env: &Env, + key: &ApiKey, + period: TimeRange, +) -> i128 { + let usage = get_api_usage(env, key.id, period); + let billable = usage.total_requests.saturating_sub(1000); + let price_per_k = key.usage_tier.price_per_thousand(); + (billable as i128).saturating_mul(price_per_k) / 1000 +} diff --git a/contracts/api/src/test.rs b/contracts/api/src/test.rs new file mode 100644 index 0000000..7de43a6 --- /dev/null +++ b/contracts/api/src/test.rs @@ -0,0 +1,353 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::Address as _, testutils::Ledger as _, Address, Bytes, BytesN, Env, +}; +use subtrackr_types::{ApiKeyConfig, ApiKeyStatus, RateLimitConfig, TimeRange, UsageTier}; + +use crate::{SubTrackrApi, SubTrackrApiClient}; + +fn setup() -> (Env, SubTrackrApiClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, SubTrackrApi); + let client = SubTrackrApiClient::new(&env, &id); + let owner = Address::generate(&env); + (env, client, owner) +} + +fn set_time(env: &Env, t: u64) { + env.ledger().with_mut(|l| l.timestamp = t); +} + +fn default_config(env: &Env) -> ApiKeyConfig { + ApiKeyConfig { + name: soroban_sdk::String::from_str(env, "test-key"), + rate_limit: RateLimitConfig { + requests_per_minute: 5, + requests_per_hour: 20, + requests_per_day: 100, + burst_limit: 3, + }, + usage_tier: UsageTier::Free, + expires_at: 0, + } +} + +fn hash_bytes(env: &Env, raw: &Bytes) -> BytesN<32> { + env.crypto().sha256(raw).into() +} + +fn make_bytes(env: &Env, s: &str) -> Bytes { + Bytes::from_slice(env, s.as_bytes()) +} + +// ── API Key Lifecycle Tests ── + +#[test] +fn test_create_api_key() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + assert!(key_id >= 1, "Key id should be >= 1"); + assert!(raw_key.len() > 0, "Raw key bytes should not be empty"); + + let stored = client.get_api_key(&key_id).unwrap(); + assert_eq!(stored.id, key_id); + assert_eq!(stored.status, ApiKeyStatus::Active); + assert_eq!(stored.created_at, 1_000_000); + assert_eq!(stored.owner, owner); + assert_eq!(stored.key_hash, hash_bytes(&env, &raw_key)); +} + +#[test] +fn test_revoke_api_key() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, _) = client.create_api_key(&owner, &default_config(&env)); + assert_eq!( + client.get_api_key(&key_id).unwrap().status, + ApiKeyStatus::Active + ); + + set_time(&env, 1_000_100); + client.revoke_api_key(&owner, &key_id); + + let key = client.get_api_key(&key_id).unwrap(); + assert_eq!(key.status, ApiKeyStatus::Revoked); + assert_eq!(key.revoked_at, 1_000_100); +} + +#[test] +fn test_rotate_api_key() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, original_raw) = client.create_api_key(&owner, &default_config(&env)); + let original_hash = hash_bytes(&env, &original_raw); + + set_time(&env, 1_001_000); + let new_raw = client.rotate_api_key(&owner, &key_id); + assert_ne!(new_raw, original_raw, "New raw key should differ from old"); + + let valid_old = client.validate_api_key(&key_id, &original_hash); + assert!(!valid_old, "Old key hash should be invalid after rotation"); + + let new_hash = hash_bytes(&env, &new_raw); + let valid_new = client.validate_api_key(&key_id, &new_hash); + assert!(valid_new, "New key hash should be valid after rotation"); +} + +#[test] +fn test_validate_api_key_active() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + assert!(client.validate_api_key(&key_id, &key_hash)); + + let wrong_hash = hash_bytes(&env, &make_bytes(&env, "wrong-key")); + assert!(!client.validate_api_key(&key_id, &wrong_hash)); +} + +#[test] +fn test_validate_revoked_key_fails() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + client.revoke_api_key(&owner, &key_id); + assert!(!client.validate_api_key(&key_id, &key_hash)); +} + +#[test] +fn test_validate_expired_key_fails() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let mut config = default_config(&env); + config.expires_at = 1_000_500; + + let (key_id, raw_key) = client.create_api_key(&owner, &config); + let key_hash = hash_bytes(&env, &raw_key); + + set_time(&env, 1_000_400); + assert!(client.validate_api_key(&key_id, &key_hash)); + + set_time(&env, 1_000_600); + assert!(!client.validate_api_key(&key_id, &key_hash)); +} + +#[test] +fn test_list_api_keys_by_owner() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (id1, _) = client.create_api_key(&owner, &default_config(&env)); + let (id2, _) = client.create_api_key(&owner, &default_config(&env)); + + let keys = client.list_api_keys(&owner); + assert_eq!(keys.len(), 2); + + let mut found1 = false; + let mut found2 = false; + for k in keys.iter() { + if k.id == id1 { found1 = true; } + if k.id == id2 { found2 = true; } + } + assert!(found1); + assert!(found2); +} + +#[test] +fn test_audit_trail() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, _) = client.create_api_key(&owner, &default_config(&env)); + + set_time(&env, 1_001_000); + client.rotate_api_key(&owner, &key_id); + + set_time(&env, 1_002_000); + client.revoke_api_key(&owner, &key_id); + + let audit = client.get_api_key_audit(&key_id); + assert_eq!(audit.len(), 2, "Should have rotate and revoke audit entries"); + assert_eq!( + audit.get(0).unwrap().action, + soroban_sdk::String::from_str(&env, "rotated") + ); + assert_eq!( + audit.get(1).unwrap().action, + soroban_sdk::String::from_str(&env, "revoked") + ); +} + +#[test] +fn test_validate_updates_last_used() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + assert_eq!(client.get_api_key(&key_id).unwrap().last_used_at, 0); + + set_time(&env, 1_001_000); + client.validate_api_key(&key_id, &key_hash); + + assert_eq!(client.get_api_key(&key_id).unwrap().last_used_at, 1_001_000); +} + +// ── Rate Limiting Tests ── + +#[test] +fn test_rate_limit_per_minute() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + for _ in 0..5 { + let status = client.check_rate_limit(&key_id, &key_hash); + assert!(status.is_allowed, "Request should be allowed within limit"); + } + + let status = client.check_rate_limit(&key_id, &key_hash); + assert!( + !status.is_allowed, + "Request should be blocked past per-minute limit" + ); + assert_eq!(status.remaining, 0); +} + +#[test] +fn test_rate_limit_reset_after_window() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + for _ in 0..5 { + client.check_rate_limit(&key_id, &key_hash); + } + let blocked = client.check_rate_limit(&key_id, &key_hash); + assert!(!blocked.is_allowed); + + set_time(&env, 1_000_060); + + let status = client.check_rate_limit(&key_id, &key_hash); + assert!(status.is_allowed, "Should reset after window passes"); +} + +#[test] +fn test_rate_limit_per_hour() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + for m in 0..3 { + for _ in 0..5 { + client.check_rate_limit(&key_id, &key_hash); + } + set_time(&env, 1_000_000 + (m as u64 + 1) * 60); + } + + for _ in 0..5 { + client.check_rate_limit(&key_id, &key_hash); + } + let status = client.check_rate_limit(&key_id, &key_hash); + assert!( + !status.is_allowed, + "Should be blocked by hourly limit" + ); +} + +#[test] +fn test_burst_limit() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + for i in 0..5 { + let status = client.check_rate_limit(&key_id, &key_hash); + assert!(status.is_allowed, "Request {} should be allowed", i + 1); + } +} + +#[test] +fn test_usage_tracking_and_report() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, raw_key) = client.create_api_key(&owner, &default_config(&env)); + let key_hash = hash_bytes(&env, &raw_key); + + for _ in 0..3 { + client.check_rate_limit(&key_id, &key_hash); + } + + set_time(&env, 1_000_100); + let period = TimeRange { + start: 1_000_000, + end: 1_000_200, + }; + let report = client.get_api_usage(&key_id, &period); + assert_eq!(report.total_requests, 3); + assert_eq!(report.key_id, key_id); +} + +#[test] +fn test_calculate_api_charge() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let mut config = default_config(&env); + config.rate_limit = RateLimitConfig { + requests_per_minute: 10_000, + requests_per_hour: 100_000, + requests_per_day: 1_000_000, + burst_limit: 10_000, + }; + config.usage_tier = UsageTier::Basic; + + let (key_id, raw_key) = client.create_api_key(&owner, &config); + let key_hash = hash_bytes(&env, &raw_key); + + for _ in 0..10 { + client.check_rate_limit(&key_id, &key_hash); + } + + let period = TimeRange { + start: 1_000_000, + end: 1_001_000, + }; + let charge = client.calculate_api_charge(&key_id, &period); + // 10 requests - 1000 free = 0 billable + assert_eq!(charge, 0); +} + +#[test] +fn test_invalid_key_check_rate_limit() { + let (env, client, owner) = setup(); + set_time(&env, 1_000_000); + + let (key_id, _raw_key) = client.create_api_key(&owner, &default_config(&env)); + let wrong_hash = hash_bytes(&env, &make_bytes(&env, "invalid-key")); + let status = client.check_rate_limit(&key_id, &wrong_hash); + assert!(!status.is_allowed); + assert_eq!(status.remaining, 0); +} From 3ff3fdada05344cb9501340a270561d5b4b15977 Mon Sep 17 00:00:00 2001 From: activatedkc Date: Thu, 28 May 2026 17:23:04 +0100 Subject: [PATCH 3/3] feat: add API key store and API keys screen --- src/screens/ApiKeysScreen.tsx | 524 ++++++++++++++++++++++++++++++++++ src/store/apiStore.ts | 161 +++++++++++ 2 files changed, 685 insertions(+) create mode 100644 src/screens/ApiKeysScreen.tsx create mode 100644 src/store/apiStore.ts diff --git a/src/screens/ApiKeysScreen.tsx b/src/screens/ApiKeysScreen.tsx new file mode 100644 index 0000000..34b7531 --- /dev/null +++ b/src/screens/ApiKeysScreen.tsx @@ -0,0 +1,524 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + TextInput, + TouchableOpacity, + Alert, + Clipboard, +} from 'react-native'; +import { Card } from '../components/common/Card'; +import { colors, spacing, typography, borderRadius } from '../utils/constants'; +import { useApiStore } from '../store/apiStore'; +import { ApiKeyStatus } from '../types/sandbox'; + +const USAGE_TIERS = [ + { key: 'free', label: 'Free', desc: '100 req/min, 10K/day' }, + { key: 'basic', label: 'Basic', desc: '1K req/min, 100K/day' }, + { key: 'pro', label: 'Pro', desc: '10K req/min, 1M/day' }, + { key: 'enterprise', label: 'Enterprise', desc: '100K req/min, 10M/day' }, +] as const; + +const ApiKeysScreen: React.FC = () => { + const { + apiKeys, + createApiKey, + revokeApiKey, + rotateApiKey, + deleteApiKey, + getKeyStats, + maskKey, + } = useApiStore(); + + const [newKeyName, setNewKeyName] = useState(''); + const [selectedTier, setSelectedTier] = useState('free'); + const [showNewKey, setShowNewKey] = useState(null); + + const stats = getKeyStats(); + + const handleCreateKey = () => { + if (!newKeyName.trim()) { + Alert.alert('Name required', 'Please provide a name for the API key.'); + return; + } + + const key = createApiKey(newKeyName.trim(), selectedTier as 'free' | 'basic' | 'pro' | 'enterprise'); + setShowNewKey(key.key); + setNewKeyName(''); + Alert.alert( + 'API Key Created', + 'Your new API key has been generated. Copy it now - it will only be shown once.' + ); + }; + + const handleCopyKey = (key: string) => { + Clipboard.setString(key); + Alert.alert('Copied', 'API key copied to clipboard.'); + }; + + const handleRevokeKey = (keyId: string, keyName: string) => { + Alert.alert('Revoke API Key', `Revoke "${keyName}"? This will immediately invalidate the key.`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Revoke', style: 'destructive', onPress: () => revokeApiKey(keyId) }, + ]); + }; + + const handleRotateKey = (keyId: string, keyName: string) => { + Alert.alert('Rotate API Key', `Rotate "${keyName}"? The current key will be replaced.`, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rotate', + onPress: () => { + const newKey = rotateApiKey(keyId); + if (newKey) { + setShowNewKey(newKey); + Alert.alert('Key Rotated', 'Your new API key is shown below.'); + } + }, + }, + ]); + }; + + const handleDeleteKey = (keyId: string, keyName: string) => { + Alert.alert('Delete API Key', `Permanently delete "${keyName}"?`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: () => deleteApiKey(keyId) }, + ]); + }; + + return ( + + + + API Keys + + Manage API keys with rate limiting and usage metering + + + + + + {stats.total} + Total + + + {stats.active} + Active + + + {stats.revoked} + Revoked + + + {stats.expired} + Expired + + + + + Generate New Key + + Key Name + + + Usage Tier + + {USAGE_TIERS.map((tier) => ( + setSelectedTier(tier.key)} + > + + {tier.label} + + {tier.desc} + + ))} + + + + Generate API Key + + + + {showNewKey && ( + + New API Key + + Copy this key now. You won't be able to see it again. + + + + {showNewKey} + + + + handleCopyKey(showNewKey)} + > + Copy Key + + setShowNewKey(null)} + > + Dismiss + + + + )} + + + Your API Keys + {apiKeys.length === 0 ? ( + + No API keys yet + + Generate an API key above to get started + + + ) : ( + apiKeys.map((key) => ( + + + + {key.name} + + {key.status} + + + + + {maskKey(key.key)} + + + + Rate Limit: {key.rateLimit?.requestsPerMinute ?? '-'}/min ·{' '} + {key.rateLimit?.requestsPerDay ?? '-'}/day + + {key.lastUsedAt && ( + + Last used: {new Date(key.lastUsedAt).toLocaleDateString()} + + )} + + Created: {new Date(key.createdAt).toLocaleDateString()} + + + + + {key.status === ApiKeyStatus.ACTIVE && ( + <> + handleCopyKey(key.key)} + > + Copy + + handleRotateKey(key.id, key.name)} + > + Rotate + + handleRevokeKey(key.id, key.name)} + > + + Revoke + + + + )} + handleDeleteKey(key.id, key.name)} + > + Delete + + + + )) + )} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + padding: spacing.lg, + gap: spacing.lg, + paddingBottom: spacing.xxl, + }, + header: { + marginBottom: spacing.sm, + }, + title: { + ...typography.h1, + color: colors.text, + }, + subtitle: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + statsGrid: { + flexDirection: 'row', + gap: spacing.md, + }, + statCard: { + flex: 1, + alignItems: 'center', + padding: spacing.md, + }, + statValue: { + ...typography.h2, + color: colors.text, + fontWeight: '800', + }, + statLabel: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + section: { + gap: spacing.md, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + }, + label: { + ...typography.body, + color: colors.textSecondary, + fontWeight: '600', + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.text, + backgroundColor: colors.surface, + }, + tierGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + tierCard: { + flex: 1, + minWidth: '45%', + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + backgroundColor: colors.surface, + }, + tierCardSelected: { + borderColor: colors.primary, + backgroundColor: `${colors.primary}15`, + }, + tierLabel: { + ...typography.body, + color: colors.text, + fontWeight: '700', + textTransform: 'capitalize', + }, + tierLabelSelected: { + color: colors.primary, + }, + tierDesc: { + ...typography.caption, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + primaryButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.md, + alignItems: 'center', + }, + primaryButtonText: { + ...typography.body, + color: colors.text, + fontWeight: '700', + }, + newKeyCard: { + borderWidth: 2, + borderColor: colors.success, + backgroundColor: `${colors.success}10`, + }, + newKeyTitle: { + ...typography.h3, + color: colors.success, + }, + newKeyWarning: { + ...typography.body, + color: colors.warning, + fontWeight: '600', + }, + keyDisplay: { + padding: spacing.md, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + }, + keyText: { + ...typography.caption, + color: colors.text, + fontFamily: 'monospace', + }, + keyActions: { + flexDirection: 'row', + gap: spacing.sm, + }, + copyButton: { + backgroundColor: colors.primary, + borderRadius: borderRadius.md, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + flex: 1, + alignItems: 'center', + }, + copyButtonText: { + ...typography.body, + color: colors.text, + fontWeight: '700', + }, + dismissButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.lg, + flex: 1, + alignItems: 'center', + }, + dismissButtonText: { + ...typography.body, + color: colors.textSecondary, + }, + emptyState: { + alignItems: 'center', + paddingVertical: spacing.xl, + }, + emptyText: { + ...typography.h3, + color: colors.text, + }, + emptySubtext: { + ...typography.body, + color: colors.textSecondary, + marginTop: spacing.xs, + }, + keyCard: { + padding: spacing.md, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.border, + marginBottom: spacing.md, + }, + keyHeader: { + gap: spacing.xs, + }, + keyNameRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + keyName: { + ...typography.body, + color: colors.text, + fontWeight: '700', + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.round, + }, + statusText: { + ...typography.caption, + color: colors.text, + fontWeight: '700', + textTransform: 'capitalize', + }, + keyValue: { + ...typography.caption, + color: colors.textSecondary, + fontFamily: 'monospace', + marginTop: spacing.sm, + }, + keyMeta: { + marginTop: spacing.sm, + gap: spacing.xs, + }, + keyMetaText: { + ...typography.caption, + color: colors.textSecondary, + }, + keyCardActions: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.md, + flexWrap: 'wrap', + }, + actionButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: borderRadius.md, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + }, + actionButtonDanger: { + borderColor: colors.error, + }, + actionButtonText: { + ...typography.caption, + color: colors.text, + fontWeight: '600', + }, +}); + +export default ApiKeysScreen; diff --git a/src/store/apiStore.ts b/src/store/apiStore.ts new file mode 100644 index 0000000..31095ae --- /dev/null +++ b/src/store/apiStore.ts @@ -0,0 +1,161 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + ApiKey, + ApiKeyStatus, + ApiKeyScope, + RateLimitConfig, + UsageStats, +} from '../types/sandbox'; + +const STORAGE_KEY = 'subtrackr-api-keys'; +const STORE_VERSION = 1; + +const generateId = (): string => + `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +const generateKeyString = (prefix = 'sk_'): string => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let key = prefix; + for (let i = 0; i < 48; i++) { + key += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return key; +}; + +const DEFAULT_RATE_LIMITS: Record = { + free: { requestsPerMinute: 100, requestsPerHour: 1000, requestsPerDay: 10000, burstLimit: 10 }, + basic: { requestsPerMinute: 1000, requestsPerHour: 10000, requestsPerDay: 100000, burstLimit: 50 }, + pro: { requestsPerMinute: 10000, requestsPerHour: 100000, requestsPerDay: 1000000, burstLimit: 200 }, + enterprise: { requestsPerMinute: 100000, requestsPerHour: 1000000, requestsPerDay: 10000000, burstLimit: 1000 }, +}; + +interface ApiKeyState { + apiKeys: ApiKey[]; + usageLogs: Record; + isLoading: boolean; + error: string | null; + + createApiKey: (name: string, tier: keyof typeof DEFAULT_RATE_LIMITS) => ApiKey; + revokeApiKey: (keyId: string) => void; + rotateApiKey: (keyId: string) => string | null; + deleteApiKey: (keyId: string) => void; + getApiKey: (keyId: string) => ApiKey | undefined; + getActiveKeys: () => ApiKey[]; + getKeyStats: () => { total: number; active: number; revoked: number; expired: number }; + maskKey: (key: string) => string; + logUsage: (keyId: string, endpoint: string, statusCode: number) => void; + clearError: () => void; +} + +export const useApiStore = create()( + persist( + (set, get) => ({ + apiKeys: [], + usageLogs: {}, + isLoading: false, + error: null, + + createApiKey: (name: string, tier: keyof typeof DEFAULT_RATE_LIMITS) => { + const now = new Date(); + const expiresAt = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000); + const rateLimit = DEFAULT_RATE_LIMITS[tier]; + const key: ApiKey = { + id: generateId(), + key: generateKeyString(), + name, + status: ApiKeyStatus.ACTIVE, + scopes: [ApiKeyScope.READ, ApiKeyScope.WRITE], + permissions: ['read', 'write'], + rateLimit, + expiresAt, + lastUsedAt: null, + createdAt: now, + updatedAt: now, + }; + set((state) => ({ apiKeys: [...state.apiKeys, key] })); + return key; + }, + + revokeApiKey: (keyId: string) => { + set((state) => ({ + apiKeys: state.apiKeys.map((k) => + k.id === keyId + ? { ...k, status: ApiKeyStatus.REVOKED, updatedAt: new Date() } + : k + ), + })); + }, + + rotateApiKey: (keyId: string) => { + const key = get().apiKeys.find((k) => k.id === keyId); + if (!key || key.status !== ApiKeyStatus.ACTIVE) return null; + const newKey = generateKeyString(); + set((state) => ({ + apiKeys: state.apiKeys.map((k) => + k.id === keyId + ? { ...k, key: newKey, lastUsedAt: null, updatedAt: new Date() } + : k + ), + })); + return newKey; + }, + + deleteApiKey: (keyId: string) => { + set((state) => ({ + apiKeys: state.apiKeys.filter((k) => k.id !== keyId), + })); + }, + + getApiKey: (keyId: string) => { + return get().apiKeys.find((k) => k.id === keyId); + }, + + getActiveKeys: () => { + return get().apiKeys.filter((k) => k.status === ApiKeyStatus.ACTIVE); + }, + + getKeyStats: () => { + const keys = get().apiKeys; + return { + total: keys.length, + active: keys.filter((k) => k.status === ApiKeyStatus.ACTIVE).length, + revoked: keys.filter((k) => k.status === ApiKeyStatus.REVOKED).length, + expired: keys.filter((k) => k.status === ApiKeyStatus.EXPIRED).length, + }; + }, + + maskKey: (key: string) => { + if (key.length <= 16) return key; + return `${key.slice(0, 12)}${'*'.repeat(key.length - 16)}${key.slice(-4)}`; + }, + + logUsage: (keyId: string, endpoint: string, statusCode: number) => { + const now = new Date(); + const stats: UsageStats = { + totalRequests: 1, + successfulRequests: statusCode < 400 ? 1 : 0, + failedRequests: statusCode >= 400 ? 1 : 0, + averageResponseTime: 0, + totalDataTransferred: 0, + periodStart: now, + periodEnd: now, + }; + set((state) => ({ + usageLogs: { + ...state.usageLogs, + [keyId]: [...(state.usageLogs[keyId] || []), stats], + }, + })); + }, + + clearError: () => set({ error: null }), + }), + { + name: STORAGE_KEY, + version: STORE_VERSION, + storage: createJSONStorage(() => AsyncStorage), + } + ) +);