Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
resolver = "2"
members = [
"api",
"proxy",
"storage",
"subscription",
Expand Down
16 changes: 16 additions & 0 deletions contracts/api/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
225 changes: 225 additions & 0 deletions contracts/api/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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::<u64>() % 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<Vec<Address>> = env.storage().instance().get(&DataKey::KeysByOwner);
let mut owners: Vec<Address> = 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<ApiKeyId> = 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<ApiKey> {
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<ApiKey> = 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<ApiKey> {
let key_ids: Vec<ApiKeyId> = env
.storage()
.instance()
.get(&DataKey::OwnerKeys(owner))
.unwrap_or(Vec::new(env));
let mut keys: Vec<ApiKey> = 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<ApiKeyAuditEntry> {
let count: u64 = env
.storage()
.instance()
.get(&DataKey::ApiKeyAuditCount)
.unwrap_or(0);
let mut entries: Vec<ApiKeyAuditEntry> = Vec::new(env);
for i in 1..=count {
let entry: Option<ApiKeyAuditEntry> =
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);
}
118 changes: 118 additions & 0 deletions contracts/api/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<ApiKey> {
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<ApiKey> {
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<ApiKeyAuditEntry> {
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)
}
}
Loading
Loading