From 8506bc9d4afbc58e91181d7af92946b3d673762e Mon Sep 17 00:00:00 2001 From: shaaibu7 Date: Sun, 31 May 2026 15:19:06 +0100 Subject: [PATCH 1/4] refactor(storage): use transient storage for pending ops, charge state & proration Move short-lived contract state out of persistent/instance storage into auto-expiring transient (temporary) storage for gas optimization: - charge_subscription: add a transient TmpChargeNonce guard (1-ledger TTL) that prevents a duplicate charge within the same ledger close - request_transfer/accept_transfer: pending transfer offers now use TmpPendingTransfer transient storage with a 7-day TTL instead of instance storage, so unaccepted offers auto-expire and stop accruing rent - preview_proration: cache the previewed prorated amount in TmpProrationScratch (TTL = one billing interval) as intermediate state - types: append TmpPendingTransfer storage key (version 7) - document storage-type selection criteria, access-pattern analysis, data-consistency rules and gas benchmarking in contracts/subscription/STORAGE.md --- contracts/subscription/STORAGE.md | 112 ++++++++++++++++++++++++++++++ contracts/subscription/src/lib.rs | 58 ++++++++++++++-- contracts/types/src/lib.rs | 8 +++ 3 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 contracts/subscription/STORAGE.md diff --git a/contracts/subscription/STORAGE.md b/contracts/subscription/STORAGE.md new file mode 100644 index 0000000..ea908be --- /dev/null +++ b/contracts/subscription/STORAGE.md @@ -0,0 +1,112 @@ +# Contract Storage Strategy & Transient Storage Refactor + +This document records the storage access-pattern analysis for the SubTrackr +subscription contract and the criteria used to pick a storage type for each +piece of state. It is the reference for the gas-optimization work that moved +short-lived state out of persistent/instance storage and into **transient +(temporary) storage**. + +## 1. Soroban storage types + +Soroban exposes three storage durabilities. Each has a different cost and +lifetime profile: + +| Type | Lifetime | Rent | Best for | +|------|----------|------|----------| +| `instance()` | Tied to the contract instance; shares one TTL with the contract | Bundled with the contract instance TTL; read on **every** invocation | Small, hot, contract-wide config (admin, counters, linked-contract addresses) | +| `persistent()` | Survives indefinitely while rent is paid; independently archivable | Per-entry rent, must be bumped to avoid archival | Durable business records that must outlive a transaction (subscriptions, plans, invoices, webhooks) | +| `temporary()` | Auto-expires after an explicit TTL (in ledgers); cannot be restored once expired | Cheapest; no long-term rent | Short-lived, reconstructible, intermediate state | + +Reference: ledger close time ≈ 5 s on mainnet, so: + +``` +60 s ≈ 12 ledgers 1 h ≈ 720 ledgers 1 day ≈ 17 280 ledgers +``` + +`secs_to_ledgers()` in `lib.rs` converts a duration to a ledger TTL. + +## 2. Storage type selection criteria + +Pick the **cheapest** durability that still satisfies the data's lifetime and +recoverability needs. Apply the checklist top-to-bottom; the first match wins: + +1. **Does it need to be readable on every invocation, and is it tiny and + contract-wide?** → `instance`. (e.g. `Admin`, `PlanCount`, + `OracleContract`.) Keep this set small — instance storage is read on every + call and inflates the base fee. + +2. **Must it survive indefinitely and be authoritative business state that + cannot be recomputed?** → `persistent`. (e.g. `Subscription`, `Plan`, + `Invoice`, `Webhook`, `WebhookDelivery`, `CreditMemo`.) Losing it would lose + money or audit history. + +3. **Is it short-lived, reconstructible, or only meaningful for a bounded + window — and is auto-expiry acceptable (or desirable) behaviour?** → + `temporary`. This covers: + - rate-limit timestamps (valid only for one rate-limit window), + - charge-state-machine guards / nonces (valid for one ledger), + - pending operations with a deadline (transfer offers), + - intermediate calculations (proration previews). + +If expiry of a value would cause **silent financial loss or corruption**, it is +NOT a candidate for transient storage — use persistent instead. + +## 3. Access-pattern analysis & decisions + +| State | Key | Before | After | Rationale | +|-------|-----|--------|-------|-----------| +| Rate-limit timestamps | `TmpLastCall(caller, fn)` | instance | **transient** (TTL = rate-limit window) | Only needs to live for `min_secs`; auto-expiry frees the entry and avoids unbounded one-per-(caller,fn) growth. | +| Charge dedup nonce | `TmpChargeNonce(sub_id)` | n/a | **transient** (TTL = 1 ledger) | A charge must happen at most once per ledger; the guard is intermediate state that should self-clear on the next ledger. | +| Pending transfer offer | `TmpPendingTransfer(sub_id)` | instance (`PendingTransfer`) | **transient** (TTL = 7 days) | A transfer offer is a *pending operation* + *temporary authorization*. It should expire if not accepted instead of persisting and accruing rent forever. | +| Proration preview | `TmpProrationScratch(sub_id)` | n/a | **transient** (TTL = 1 billing interval) | A previewed amount is an intermediate calculation only relevant until the change is confirmed or abandoned. | +| Subscriptions / Plans / Invoices / Webhooks / Credit memos | various | persistent | **persistent** (unchanged) | Authoritative records that must outlive transactions and be archival-safe. | +| Admin / counters / linked contracts | `Admin`, `*Count`, `OracleContract`, … | instance | **instance** (unchanged) | Small, contract-wide, read on most calls. | + +### Data-consistency notes when mixing storage types + +- **Never** read a `Tmp*` key from `instance`/`persistent` or vice-versa — a + durability mismatch returns `None`. The `Tmp` prefix on every transient key + makes the intended durability explicit at the call site. +- Transient reads must always tolerate `None` (expired/never-written) and treat + it as the safe default (e.g. "no pending transfer", "not rate-limited", + "no cached preview"). The contract code does this everywhere it reads a + `Tmp*` key. +- **Migration:** the rate-limit refactor (Issue #395) intentionally ignores any + legacy instance-backed `LastCall` entries; worst case a caller gets one extra + call immediately after upgrade, then the limit re-applies. The transfer + refactor likewise reads only `TmpPendingTransfer`; any in-flight legacy + `PendingTransfer` offer would need to be re-requested after upgrade. +- TTLs are sized from the data's real lifetime via `secs_to_ledgers()`, with a + floor of 1 ledger so nothing is written already-expired. + +## 4. Benchmarking gas before/after + +Use the in-repo gas profiler to compare costs around the refactor: + +```bash +# Build the optimized wasm +cd contracts && cargo build --release --target wasm32-unknown-unknown -p subtrackr-subscription + +# Measure per-function resource usage with the gas profiler module +# (see contracts/subscription/src/gas_profiler.rs and gas_storage.rs) +``` + +Expected directional results (transient vs the persistent/instance baseline): + +- **Rate-limited functions** (`charge_subscription`, `request_refund`, …): + lower instance-storage footprint → smaller per-invocation base fee, and no + long-term rent for last-call timestamps. +- **`request_transfer` / `accept_transfer`**: pending-transfer entries no longer + accrue persistent rent; offers self-expire. +- **`charge_subscription`**: one extra cheap transient read+write for the nonce + guard, traded for double-charge protection within a ledger. + +Record concrete `cpu_insns` / `mem_bytes` / rent figures from the profiler in +the PR description when running on a target network, since absolute numbers +depend on the network's fee schedule. + +## 5. Adding new state — quick rule + +> Default to `temporary` for anything short-lived or recomputable; reach for +> `persistent` only when the value is authoritative and must survive; reserve +> `instance` for tiny contract-wide config read on most calls. diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index bf120bc..df9f028 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -12,6 +12,11 @@ use subtrackr_types::{ /// Billing interval in seconds. const MAX_PAUSE_DURATION: u64 = 2_592_000; // 30 days +/// How long an unaccepted subscription-transfer offer remains valid before it +/// expires. Pending transfers live in transient storage, so once this window +/// elapses the offer is removed automatically without an explicit cleanup call. +const PENDING_TRANSFER_TTL_SECS: u64 = 604_800; // 7 days + const STORAGE_VERSION: u32 = 2; #[soroban_sdk::contracttype] @@ -970,6 +975,25 @@ impl SubTrackrSubscription { sub.subscriber.require_auth(); + // ── Charge state machine guard (transient storage) ────────────────── + // A subscription must be charged at most once per ledger close. We + // record the current ledger sequence as a charge nonce in TEMPORARY + // storage keyed by subscription_id. The entry is given a 1-ledger TTL + // so it self-clears on the next ledger and never accrues persistent + // rent. This is intermediate, short-lived state — exactly what + // transient storage is for — and it cheaply prevents a duplicate + // charge from racing through within the same ledger. + let nonce_key = StorageKey::TmpChargeNonce(subscription_id); + let ledger_seq = env.ledger().sequence() as u64; + let in_progress: Option = storage_temporary_get(&env, &storage, nonce_key.clone()); + if let Some(prev_seq) = in_progress { + assert!( + prev_seq != ledger_seq, + "Duplicate charge attempt within the same ledger" + ); + } + storage_temporary_set(&env, &storage, nonce_key, ledger_seq, 1); + if check_and_resume_internal(&env, &mut sub) { storage_persistent_set( &env, @@ -1175,11 +1199,17 @@ impl SubTrackrSubscription { ); assert!(sub.subscriber != recipient, "Cannot transfer to self"); - storage_instance_set( + // Pending transfers are a short-lived "pending operation" that also + // grants the recipient temporary authorization to accept. They belong + // in transient storage: the offer should not persist (and accrue rent) + // indefinitely, and auto-expiry after PENDING_TRANSFER_TTL_SECS gives + // the offer a natural deadline. + storage_temporary_set( &env, &storage, - StorageKey::PendingTransfer(subscription_id), + StorageKey::TmpPendingTransfer(subscription_id), recipient.clone(), + secs_to_ledgers(PENDING_TRANSFER_TTL_SECS), ); env.events().publish( @@ -1209,8 +1239,8 @@ impl SubTrackrSubscription { .expect("Subscription not found"); let pending_recipient: Address = - storage_instance_get(&env, &storage, StorageKey::PendingTransfer(subscription_id)) - .expect("No pending transfer for this subscription"); + storage_temporary_get(&env, &storage, StorageKey::TmpPendingTransfer(subscription_id)) + .expect("No pending transfer for this subscription (it may have expired)"); assert!( pending_recipient == recipient, "Transfer recipient mismatch" @@ -1262,7 +1292,7 @@ impl SubTrackrSubscription { sub, ); - storage_instance_remove(&env, &storage, StorageKey::PendingTransfer(subscription_id)); + storage_temporary_remove(&env, &storage, StorageKey::TmpPendingTransfer(subscription_id)); env.events().publish( (String::from_str(&env, "transfer_accepted"), subscription_id), @@ -1651,7 +1681,23 @@ pub fn preview_proration( EffectiveDate::EndOfPeriod }; - proration::preview_proration(&env, &sub, old_plan.price, new_plan.price, effective) + let result = proration::preview_proration(&env, &sub, old_plan.price, new_plan.price, effective); + + // Cache the previewed prorated amount in transient storage so a client can + // preview then confirm without recomputing. This is purely intermediate + // calculation state, so it lives in TEMPORARY storage and expires after one + // billing interval — no persistent rent for a value that is only relevant + // until the change is confirmed or abandoned. + let signed_amount: i128 = if result.is_credit { -result.amount } else { result.amount }; + storage_temporary_set( + &env, + &storage, + StorageKey::TmpProrationScratch(subscription_id), + signed_amount, + secs_to_ledgers(sub.next_charge_at.saturating_sub(sub.last_charged_at).max(1)), + ); + + result } /// Execute a plan change with proration diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 38b872e..a2a1592 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -700,6 +700,14 @@ pub enum StorageKey { /// Temporary nonce used to deduplicate rapid charge attempts within a /// single ledger sequence window. Expires after one ledger close (~5 s). TmpChargeNonce(u64), + + // ── Added in storage version 7 (transient pending operations) ── + /// Pending subscription-transfer authorization keyed by subscription_id. + /// Holds the recipient address that is temporarily authorized to accept + /// the transfer. Stored in TEMPORARY storage so an unaccepted transfer + /// offer auto-expires (default 7 days) instead of lingering forever in + /// instance storage. Replaces the instance-backed StorageKey::PendingTransfer. + TmpPendingTransfer(u64), } /// Slippage protection bounds for oracle-based pricing. From 0a27553a4327922b48ebe691d3df101da1b6666b Mon Sep 17 00:00:00 2001 From: shaaibu7 Date: Sun, 31 May 2026 15:29:08 +0100 Subject: [PATCH 2/4] feat(security): implement PII encryption at rest for GDPR compliance Add a new subtrackr-security Soroban contract plus a matching client service that encrypt subscriber PII, with interoperable ciphertext formats. Contract (contracts/security): - hash-CTR stream cipher (SHA-256 keystream) with encrypt-and-MAC integrity, since Soroban exposes no native symmetric cipher - encrypt_data / decrypt_data with self-describing EncryptedData envelope (key version + per-record nonce + MAC) - versioned keys with rotate_key: old versions retained for decrypting historical records, deactivated for new encryptions - access-control list (grant_access/revoke_access/is_authorized) gating both encryption and decryption; admin implicitly authorized - export_encrypted re-encrypts records under the current key for data export - registered in the contracts workspace Client (app/services/encryptionService.ts): - mirrors the contract algorithm (pure-JS SHA-256) so formats interoperate - PII_FIELDS registry, record-level encrypt/decrypt helpers - versioned key store (AsyncStorage) with rotation, pluggable AccessController - exportEncrypted for GDPR data export Docs: contracts/security/README.md covering algorithm, key management, rotation, access control and edge cases (key loss, performance). --- app/services/encryptionService.ts | 461 +++++++++++++++++++++++++++ contracts/Cargo.toml | 1 + contracts/security/Cargo.toml | 17 + contracts/security/README.md | 54 ++++ contracts/security/src/encryption.rs | 113 +++++++ contracts/security/src/lib.rs | 280 ++++++++++++++++ 6 files changed, 926 insertions(+) create mode 100644 app/services/encryptionService.ts create mode 100644 contracts/security/Cargo.toml create mode 100644 contracts/security/README.md create mode 100644 contracts/security/src/encryption.rs create mode 100644 contracts/security/src/lib.rs diff --git a/app/services/encryptionService.ts b/app/services/encryptionService.ts new file mode 100644 index 0000000..3a1ad0f --- /dev/null +++ b/app/services/encryptionService.ts @@ -0,0 +1,461 @@ +// ════════════════════════════════════════════════════════════════ +// ENCRYPTION SERVICE - PII encryption at rest (GDPR compliance) +// ════════════════════════════════════════════════════════════════ +// +// Mirrors the `subtrackr-security` Soroban contract on the client so the app +// can encrypt subscriber PII before it is persisted or sent on-chain, and +// decrypt it only for authorized actors. +// +// Algorithm (identical to the contract so the formats are interoperable): +// * Cipher: hash-based stream cipher in CTR mode. For each 32-byte block i, +// keystream_i = SHA-256(key || nonce || counter_i_be) +// XOR-ed with the plaintext. Symmetric: encrypt == decrypt. +// * Integrity: encrypt-and-MAC tag = SHA-256(key || nonce || plaintext), +// recomputed and compared on decrypt to detect tampering / wrong key. +// * Keys are versioned for rotation; every record records the key version +// used so it stays decryptable after rotations. A fresh nonce is derived +// per record from a CSPRNG, so (key, nonce) pairs never repeat. +// +// The SHA-256 implementation is pure JS (no native dependency) so the service +// is deterministic and produces byte-identical output to the contract for the +// same (key, nonce, plaintext). + +import 'react-native-get-random-values'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// ════════════════════════════════════════════════════════════════ +// Types +// ════════════════════════════════════════════════════════════════ + +/** Self-describing ciphertext envelope. Hex-encoded for safe storage/transport. */ +export interface EncryptedData { + /** Version of the key used to encrypt this record. */ + keyVersion: number; + /** 32-byte per-record nonce (hex). */ + nonce: string; + /** XOR-keystream ciphertext (hex). */ + ciphertext: string; + /** SHA-256(key || nonce || plaintext) integrity tag (hex). */ + mac: string; +} + +/** A versioned 32-byte symmetric key. */ +export interface EncryptionKey { + version: number; + /** 32 raw key bytes. */ + keyMaterial: Uint8Array; + createdAt: number; + /** Whether this key may be used for new encryptions (false once rotated out). */ + active: boolean; +} + +/** Decides whether a given actor may decrypt PII. */ +export type AccessController = (actorId: string) => boolean; + +// ════════════════════════════════════════════════════════════════ +// PII field registry +// ════════════════════════════════════════════════════════════════ +// +// The subscriber fields that constitute personal data and MUST be encrypted +// at rest. Centralized so the same list drives encryption, export and audits. + +export const PII_FIELDS = [ + 'email', + 'fullName', + 'phone', + 'billingAddress', + 'taxId', + 'walletAddress', + 'ipAddress', +] as const; + +export type PiiField = (typeof PII_FIELDS)[number]; + +export function isPiiField(field: string): field is PiiField { + return (PII_FIELDS as readonly string[]).includes(field); +} + +// ════════════════════════════════════════════════════════════════ +// SHA-256 (pure JS) — matches the contract's env.crypto().sha256 +// ════════════════════════════════════════════════════════════════ + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n)); + +/** SHA-256 over `msg`, returning a 32-byte digest. */ +export function sha256(msg: Uint8Array): Uint8Array { + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + + const bitLen = msg.length * 8; + // Pad: 0x80, then zeros, then 64-bit big-endian length. + const withOne = msg.length + 1; + const totalLen = withOne + ((56 - (withOne % 64) + 64) % 64) + 8; + const padded = new Uint8Array(totalLen); + padded.set(msg); + padded[msg.length] = 0x80; + // 64-bit length (high word is 0 for our message sizes). + const dv = new DataView(padded.buffer); + dv.setUint32(totalLen - 4, bitLen >>> 0, false); + dv.setUint32(totalLen - 8, Math.floor(bitLen / 0x100000000) >>> 0, false); + + const w = new Uint32Array(64); + for (let off = 0; off < totalLen; off += 64) { + for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + let a = h[0]; + let b = h[1]; + let c = h[2]; + let d = h[3]; + let e = h[4]; + let f = h[5]; + let g = h[6]; + let hh = h[7]; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + + const out = new Uint8Array(32); + const odv = new DataView(out.buffer); + for (let i = 0; i < 8; i++) odv.setUint32(i * 4, h[i], false); + return out; +} + +// ════════════════════════════════════════════════════════════════ +// Byte / hex / utf8 helpers +// ════════════════════════════════════════════════════════════════ + +export function toHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0'); + return s; +} + +export function fromHex(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16); + return out; +} + +function utf8Encode(str: string): Uint8Array { + if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str); + // Minimal fallback. + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + let c = str.charCodeAt(i); + if (c < 0x80) bytes.push(c); + else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(bytes); +} + +function utf8Decode(bytes: Uint8Array): string { + if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes); + let s = ''; + for (let i = 0; i < bytes.length; ) { + const c = bytes[i++]; + if (c < 0x80) s += String.fromCharCode(c); + else if (c < 0xe0) s += String.fromCharCode(((c & 0x1f) << 6) | (bytes[i++] & 0x3f)); + else s += String.fromCharCode(((c & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)); + } + return s; +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const len = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(len); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +function u32be(n: number): Uint8Array { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, n >>> 0, false); + return b; +} + +function randomBytes(len: number): Uint8Array { + const out = new Uint8Array(len); + const g = (globalThis as { crypto?: Crypto }).crypto; + if (g && typeof g.getRandomValues === 'function') { + g.getRandomValues(out); + return out; + } + throw new Error('No CSPRNG available; ensure react-native-get-random-values is loaded'); +} + +// ════════════════════════════════════════════════════════════════ +// Core cipher (mirrors contracts/security/src/encryption.rs) +// ════════════════════════════════════════════════════════════════ + +function keystreamBlock(key: Uint8Array, nonce: Uint8Array, counter: number): Uint8Array { + return sha256(concat(key, nonce, u32be(counter))); +} + +/** XOR stream cipher in CTR mode. Symmetric: same call encrypts and decrypts. */ +export function xorCrypt(key: Uint8Array, nonce: Uint8Array, input: Uint8Array): Uint8Array { + const out = new Uint8Array(input.length); + let block = 0; + let ks = keystreamBlock(key, nonce, block); + for (let i = 0; i < input.length; i++) { + const pos = i % 32; + if (i !== 0 && pos === 0) { + block += 1; + ks = keystreamBlock(key, nonce, block); + } + out[i] = input[i] ^ ks[pos]; + } + return out; +} + +function computeMac(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array): Uint8Array { + return sha256(concat(key, nonce, plaintext)); +} + +function ctEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +// ════════════════════════════════════════════════════════════════ +// Key store +// ════════════════════════════════════════════════════════════════ + +const KEY_STORE_KEY = 'subtrackr-encryption-keys'; +const CURRENT_VERSION_KEY = 'subtrackr-encryption-current-version'; + +interface StoredKey { + version: number; + keyMaterialHex: string; + createdAt: number; + active: boolean; +} + +async function loadStoredKeys(): Promise> { + const raw = await AsyncStorage.getItem(KEY_STORE_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; +} + +async function persistKeys(keys: Record): Promise { + await AsyncStorage.setItem(KEY_STORE_KEY, JSON.stringify(keys)); +} + +// ════════════════════════════════════════════════════════════════ +// Service +// ════════════════════════════════════════════════════════════════ + +export interface EncryptionServiceOptions { + /** Gate for who may decrypt PII. Defaults to "everyone" — override in apps. */ + accessController?: AccessController; +} + +export class EncryptionService { + private accessController: AccessController; + + constructor(opts: EncryptionServiceOptions = {}) { + this.accessController = opts.accessController ?? (() => true); + } + + /** Replace the access controller (e.g. wire to RBAC). */ + setAccessController(controller: AccessController): void { + this.accessController = controller; + } + + // ── Key management ───────────────────────────────────────────── + + /** Initialize the key store with version 1 if it does not yet exist. */ + async initialize(): Promise { + const keys = await loadStoredKeys(); + if (Object.keys(keys).length > 0) { + return this.getCurrentKey(); + } + const key: StoredKey = { + version: 1, + keyMaterialHex: toHex(randomBytes(32)), + createdAt: Date.now(), + active: true, + }; + keys[1] = key; + await persistKeys(keys); + await AsyncStorage.setItem(CURRENT_VERSION_KEY, '1'); + return this.toKey(key); + } + + /** + * Rotate the key: deactivate the current version (keeping it for decrypting + * old data) and create a new active version. Returns the new version. + */ + async rotateKey(newKeyMaterial?: Uint8Array): Promise { + const keys = await loadStoredKeys(); + const current = await this.getCurrentVersion(); + if (keys[current]) { + keys[current].active = false; + } + const newVersion = current + 1; + keys[newVersion] = { + version: newVersion, + keyMaterialHex: toHex(newKeyMaterial ?? randomBytes(32)), + createdAt: Date.now(), + active: true, + }; + await persistKeys(keys); + await AsyncStorage.setItem(CURRENT_VERSION_KEY, String(newVersion)); + return newVersion; + } + + async getCurrentVersion(): Promise { + const v = await AsyncStorage.getItem(CURRENT_VERSION_KEY); + if (!v) throw new Error('Encryption not initialized — call initialize() first'); + return parseInt(v, 10); + } + + async getCurrentKey(): Promise { + return this.getKey(await this.getCurrentVersion()); + } + + private async getKey(version: number): Promise { + const keys = await loadStoredKeys(); + const k = keys[version]; + if (!k) throw new Error(`Encryption key version ${version} not found`); + return this.toKey(k); + } + + private toKey(k: StoredKey): EncryptionKey { + return { + version: k.version, + keyMaterial: fromHex(k.keyMaterialHex), + createdAt: k.createdAt, + active: k.active, + }; + } + + // ── Encrypt / decrypt ────────────────────────────────────────── + + /** Encrypt a UTF-8 string under the current active key. */ + async encrypt(plaintext: string): Promise { + const key = await this.getCurrentKey(); + if (!key.active) throw new Error('Current key is not active'); + const data = utf8Encode(plaintext); + const nonce = randomBytes(32); + const ciphertext = xorCrypt(key.keyMaterial, nonce, data); + const mac = computeMac(key.keyMaterial, nonce, data); + return { + keyVersion: key.version, + nonce: toHex(nonce), + ciphertext: toHex(ciphertext), + mac: toHex(mac), + }; + } + + /** Decrypt to a UTF-8 string. Enforces access control and integrity. */ + async decrypt(encrypted: EncryptedData, actorId = 'system'): Promise { + if (!this.accessController(actorId)) { + throw new Error(`Access denied: ${actorId} is not authorized to decrypt PII`); + } + const key = await this.getKey(encrypted.keyVersion); + const nonce = fromHex(encrypted.nonce); + const plaintext = xorCrypt(key.keyMaterial, nonce, fromHex(encrypted.ciphertext)); + const mac = computeMac(key.keyMaterial, nonce, plaintext); + if (!ctEqual(mac, fromHex(encrypted.mac))) { + throw new Error('MAC verification failed: data tampered or wrong key'); + } + return utf8Decode(plaintext); + } + + // ── Record-level helpers ─────────────────────────────────────── + + /** + * Encrypt the PII fields of a record in place, returning a copy where each + * PII field's value is replaced by an EncryptedData envelope. Non-PII fields + * are left untouched. + */ + async encryptRecord>( + record: T, + fields: readonly string[] = PII_FIELDS, + ): Promise> { + const out: Record = { ...record }; + for (const field of fields) { + const value = record[field]; + if (value !== undefined && value !== null && value !== '') { + out[field] = await this.encrypt(String(value)); + } + } + return out; + } + + /** Inverse of encryptRecord: decrypt each EncryptedData-valued PII field. */ + async decryptRecord( + record: Record, + fields: readonly string[] = PII_FIELDS, + actorId = 'system', + ): Promise> { + const out: Record = { ...record }; + for (const field of fields) { + const value = record[field]; + if (value && typeof value === 'object' && 'ciphertext' in (value as object)) { + out[field] = await this.decrypt(value as EncryptedData, actorId); + } + } + return out; + } + + // ── Data export ──────────────────────────────────────────────── + + /** + * Re-encrypt an existing record under the current active key (e.g. for a + * GDPR data-export bundle or migrating ciphertext after a rotation). The + * plaintext is decrypted and immediately re-encrypted; it is never exposed. + */ + async exportEncrypted(encrypted: EncryptedData, actorId = 'system'): Promise { + const plaintext = await this.decrypt(encrypted, actorId); + return this.encrypt(plaintext); + } +} + +/** Shared default instance (override the access controller per app context). */ +export const encryptionService = new EncryptionService(); diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index ca8e12c..c7ed4fa 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -11,6 +11,7 @@ members = [ "credit", "metering", "access_control", + "security", ] [profile.release] diff --git a/contracts/security/Cargo.toml b/contracts/security/Cargo.toml new file mode 100644 index 0000000..c0fbba3 --- /dev/null +++ b/contracts/security/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "subtrackr-security" +version = "0.1.0" +edition = "2021" +authors = ["SubTrackr Team"] +description = "SubTrackr security contract: PII encryption at rest, key management & rotation (Soroban)" + +[lib] +name = "subtrackr_security" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "21.0.0" + +[dev-dependencies] +soroban-sdk = { version = "21.0.0", features = ["testutils"] } diff --git a/contracts/security/README.md b/contracts/security/README.md new file mode 100644 index 0000000..a8294ce --- /dev/null +++ b/contracts/security/README.md @@ -0,0 +1,54 @@ +# SubTrackr Security — PII Encryption at Rest + +Encrypts subscriber PII for GDPR compliance, on-chain (`subtrackr-security` +contract) and on the client (`app/services/encryptionService.ts`). Both sides +use the **same** algorithm so ciphertext is interoperable. + +## Algorithm + +Soroban exposes no symmetric block cipher, only `sha256`. We use a hash-based +stream cipher in CTR mode plus an encrypt-and-MAC integrity tag: + +- **Cipher** — for 32-byte block `i`: `keystream_i = SHA-256(key || nonce || + counter_i_be)`, XOR-ed with the data. XOR is symmetric, so the same routine + encrypts and decrypts. +- **Integrity** — `mac = SHA-256(key || nonce || plaintext)`, recomputed and + compared on decrypt to detect tampering or decryption under the wrong key. +- **Nonce** — a fresh 32-byte nonce per record (CSPRNG on the client; a + monotonic counter mixed with ledger context in the contract). A `(key, nonce)` + pair is never reused. + +## PII fields + +The fields treated as personal data (`PII_FIELDS`): `email`, `fullName`, +`phone`, `billingAddress`, `taxId`, `walletAddress`, `ipAddress`. Encrypt only +these; leave non-PII fields in the clear for indexing/queries. + +## Keys, rotation & management + +- Keys are **versioned**. `initialize` creates version 1. +- `rotate_key` deactivates the current version (no longer used for new + encryptions) and installs a new active version. **Old versions are retained** + so historical records stay decryptable. Each `EncryptedData` envelope records + its `key_version`, so the right key is always selected on decrypt. +- `export_encrypted` / `exportEncrypted` re-encrypt a record under the current + key — used for GDPR data export and for migrating ciphertext after rotation. + +## Access control + +Encryption and decryption are gated. On-chain: the admin plus addresses granted +via `grant_access`. On the client: an injectable `AccessController` predicate +(wire it to RBAC in the app). Decryption verifies the MAC before returning data. + +## Edge cases + +- **Key loss** — if a key version's material is lost, records encrypted under + it are unrecoverable by design (no backdoor). Operationally, back up key + material in a KMS/HSM and rotate rather than discard; never delete a retained + key version that still protects live data. +- **Performance** — SHA-256 hashing scales linearly with payload size (one hash + per 32-byte block plus one MAC hash). Encrypt only PII fields, keep payloads + small, and prefer the client to encrypt before submitting to chain so the + contract mostly stores opaque ciphertext. +- **Tamper / wrong key** — surfaced as a MAC verification failure (panic on + chain, thrown error on client) rather than returning garbage plaintext. diff --git a/contracts/security/src/encryption.rs b/contracts/security/src/encryption.rs new file mode 100644 index 0000000..db30966 --- /dev/null +++ b/contracts/security/src/encryption.rs @@ -0,0 +1,113 @@ +//! Encryption primitives and types for PII encryption at rest. +//! +//! Soroban's host does not expose a symmetric block cipher (no AES), but it +//! does expose `sha256`. We therefore implement a **hash-based stream cipher +//! in counter (CTR) mode**: for each 32-byte block `i` of the message the +//! keystream block is `SHA-256(key || nonce || counter_i)`, which is XOR-ed +//! with the plaintext. Because XOR is symmetric, the exact same routine both +//! encrypts and decrypts. +//! +//! Integrity is provided by an encrypt-and-MAC tag, `SHA-256(key || nonce || +//! plaintext)`, recomputed and compared on decryption to detect tampering or +//! decryption under the wrong key version. +//! +//! Confidentiality depends on each `(key, nonce)` pair being unique — never +//! reuse a nonce with the same key. `next_nonce` in `lib.rs` derives a fresh +//! nonce per encryption from a monotonic counter mixed with ledger context. + +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Env}; + +/// Storage keys for the security contract. +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + /// Contract administrator (manages keys and the access list). + Admin, + /// Version number of the currently active encryption key. + CurrentKeyVersion, + /// Stored `EncryptionKey` material keyed by version. Old versions are + /// retained (but deactivated) so previously encrypted data stays readable. + Key(u32), + /// Whether an address is authorized to encrypt/decrypt PII. + Authorized(Address), + /// Total number of key rotations performed (audit). + RotationCount, + /// Monotonic counter used to derive unique per-record nonces. + NonceCounter, +} + +/// A versioned symmetric encryption key. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct EncryptionKey { + /// Monotonic version. Version 1 is created at `initialize`. + pub version: u32, + /// 32-byte symmetric key material. + pub key_material: BytesN<32>, + /// Ledger timestamp when this key version was created. + pub created_at: u64, + /// Whether this key may be used for *new* encryptions. Rotated-out keys + /// are set to `false` but kept so old ciphertext can still be decrypted. + pub active: bool, +} + +/// Self-describing ciphertext envelope. +/// +/// It carries the key version and per-record nonce so the data can always be +/// decrypted later, even after one or more key rotations. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct EncryptedData { + /// Version of the key used to encrypt this record. + pub key_version: u32, + /// Per-record nonce. Unique for every encryption under a given key. + pub nonce: BytesN<32>, + /// The XOR-keystream ciphertext (same length as the plaintext). + pub ciphertext: Bytes, + /// `SHA-256(key || nonce || plaintext)` integrity tag. + pub mac: BytesN<32>, +} + +/// Derive the keystream block for counter `counter`: +/// `SHA-256(key || nonce || counter_be)`. +fn keystream_block(env: &Env, key: &BytesN<32>, nonce: &BytesN<32>, counter: u32) -> [u8; 32] { + let mut buf = Bytes::from_array(env, &key.to_array()); + buf.append(&Bytes::from_array(env, &nonce.to_array())); + buf.extend_from_array(&counter.to_be_bytes()); + env.crypto().sha256(&buf).to_array() +} + +/// Encrypt or decrypt `input` with the hash-CTR stream cipher. Symmetric: the +/// same call both encrypts (plaintext -> ciphertext) and decrypts. +pub fn xor_crypt(env: &Env, key: &BytesN<32>, nonce: &BytesN<32>, input: &Bytes) -> Bytes { + let mut out = Bytes::new(env); + let len = input.len(); + let mut block_index: u32 = 0; + let mut ks = keystream_block(env, key, nonce, block_index); + + let mut i: u32 = 0; + while i < len { + let pos = i % 32; + if i != 0 && pos == 0 { + block_index += 1; + ks = keystream_block(env, key, nonce, block_index); + } + let b = input.get(i).unwrap_or(0); + out.push_back(b ^ ks[pos as usize]); + i += 1; + } + out +} + +/// Compute the integrity MAC `SHA-256(key || nonce || plaintext)`. +pub fn compute_mac( + env: &Env, + key: &BytesN<32>, + nonce: &BytesN<32>, + plaintext: &Bytes, +) -> BytesN<32> { + let mut buf = Bytes::from_array(env, &key.to_array()); + buf.append(&Bytes::from_array(env, &nonce.to_array())); + buf.append(plaintext); + env.crypto().sha256(&buf).to_bytes() +} diff --git a/contracts/security/src/lib.rs b/contracts/security/src/lib.rs new file mode 100644 index 0000000..987336f --- /dev/null +++ b/contracts/security/src/lib.rs @@ -0,0 +1,280 @@ +#![no_std] +//! SubTrackr Security contract. +//! +//! Provides encryption at rest for subscriber PII (GDPR compliance), with +//! versioned symmetric keys, key rotation, an access-control list for who may +//! encrypt/decrypt, and re-encryption support for data export. +//! +//! Key lifetime model: +//! * `initialize` creates key version 1 and marks the caller as admin. +//! * `rotate_key` deactivates the current key (so it is no longer used for +//! *new* encryptions) and installs a new active version. Old key versions +//! are retained so previously encrypted records remain decryptable. +//! * Every `EncryptedData` envelope records the `key_version` used, so the +//! contract always selects the right key on decryption. + +mod encryption; + +use encryption::{compute_mac, xor_crypt, DataKey, EncryptedData, EncryptionKey}; +use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, Symbol}; + +#[contract] +pub struct SubTrackrSecurity; + +#[contractimpl] +impl SubTrackrSecurity { + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /// Initialize the contract with an admin and the first encryption key. + /// The admin is implicitly authorized to encrypt and decrypt. + pub fn initialize(env: Env, admin: Address, initial_key: BytesN<32>) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("Already initialized"); + } + admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &admin); + + let key = EncryptionKey { + version: 1, + key_material: initial_key, + created_at: env.ledger().timestamp(), + active: true, + }; + env.storage().persistent().set(&DataKey::Key(1), &key); + env.storage() + .instance() + .set(&DataKey::CurrentKeyVersion, &1u32); + env.storage().instance().set(&DataKey::RotationCount, &0u64); + env.storage().instance().set(&DataKey::NonceCounter, &0u64); + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + fn admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized") + } + + fn require_admin(env: &Env) { + Self::admin(env).require_auth(); + } + + /// Authorized = admin, or an address explicitly granted access. + fn is_authorized_internal(env: &Env, account: &Address) -> bool { + if *account == Self::admin(env) { + return true; + } + env.storage() + .persistent() + .get(&DataKey::Authorized(account.clone())) + .unwrap_or(false) + } + + fn require_authorized(env: &Env, account: &Address) { + assert!( + Self::is_authorized_internal(env, account), + "Unauthorized: account may not access PII encryption" + ); + } + + fn load_key(env: &Env, version: u32) -> EncryptionKey { + env.storage() + .persistent() + .get(&DataKey::Key(version)) + .expect("Encryption key version not found") + } + + /// Derive a fresh, unique nonce from a monotonic counter mixed with the + /// active key material and ledger context. Never reuses a nonce per key. + fn next_nonce(env: &Env, key_material: &BytesN<32>) -> BytesN<32> { + let counter: u64 = env + .storage() + .instance() + .get(&DataKey::NonceCounter) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&DataKey::NonceCounter, &counter); + + let mut buf = Bytes::from_array(env, &key_material.to_array()); + buf.extend_from_array(&counter.to_be_bytes()); + buf.extend_from_array(&env.ledger().timestamp().to_be_bytes()); + buf.extend_from_array(&env.ledger().sequence().to_be_bytes()); + env.crypto().sha256(&buf).to_bytes() + } + + // ── Core API ────────────────────────────────────────────────────────────── + + /// Encrypt PII under the current active key. Caller must be authorized. + pub fn encrypt_data(env: Env, caller: Address, data: Bytes) -> EncryptedData { + caller.require_auth(); + Self::require_authorized(&env, &caller); + + let version: u32 = env + .storage() + .instance() + .get(&DataKey::CurrentKeyVersion) + .expect("Not initialized"); + let key = Self::load_key(&env, version); + assert!(key.active, "Current key is not active"); + + let nonce = Self::next_nonce(&env, &key.key_material); + let ciphertext = xor_crypt(&env, &key.key_material, &nonce, &data); + let mac = compute_mac(&env, &key.key_material, &nonce, &data); + + EncryptedData { + key_version: version, + nonce, + ciphertext, + mac, + } + } + + /// Decrypt PII. Caller must be authorized. Verifies the integrity MAC and + /// panics on tamper / wrong key. + pub fn decrypt_data(env: Env, caller: Address, encrypted: EncryptedData) -> Bytes { + caller.require_auth(); + Self::require_authorized(&env, &caller); + + let key = Self::load_key(&env, encrypted.key_version); + let plaintext = xor_crypt(&env, &key.key_material, &encrypted.nonce, &encrypted.ciphertext); + let mac = compute_mac(&env, &key.key_material, &encrypted.nonce, &plaintext); + assert!( + mac == encrypted.mac, + "MAC verification failed: data tampered or wrong key" + ); + plaintext + } + + // ── Key management & rotation ────────────────────────────────────────────── + + /// Rotate the active encryption key. The previous key is deactivated for + /// new encryptions but retained for decrypting historical records. Returns + /// the new key version. Admin only. + pub fn rotate_key(env: Env, new_key: BytesN<32>) -> u32 { + Self::require_admin(&env); + + let current: u32 = env + .storage() + .instance() + .get(&DataKey::CurrentKeyVersion) + .expect("Not initialized"); + + let mut old = Self::load_key(&env, current); + old.active = false; + env.storage().persistent().set(&DataKey::Key(current), &old); + + let new_version = current + 1; + let key = EncryptionKey { + version: new_version, + key_material: new_key, + created_at: env.ledger().timestamp(), + active: true, + }; + env.storage() + .persistent() + .set(&DataKey::Key(new_version), &key); + env.storage() + .instance() + .set(&DataKey::CurrentKeyVersion, &new_version); + + let rotations: u64 = env + .storage() + .instance() + .get(&DataKey::RotationCount) + .unwrap_or(0) + + 1; + env.storage() + .instance() + .set(&DataKey::RotationCount, &rotations); + + env.events() + .publish((Symbol::new(&env, "key_rotated"),), (current, new_version)); + new_version + } + + /// Current active key version. + pub fn current_key_version(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::CurrentKeyVersion) + .expect("Not initialized") + } + + /// Number of rotations performed (audit metric). + pub fn rotation_count(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::RotationCount) + .unwrap_or(0) + } + + // ── Access control ────────────────────────────────────────────────────────── + + /// Grant an account permission to encrypt/decrypt PII. Admin only. + pub fn grant_access(env: Env, account: Address) { + Self::require_admin(&env); + env.storage() + .persistent() + .set(&DataKey::Authorized(account.clone()), &true); + env.events() + .publish((Symbol::new(&env, "access_granted"),), account); + } + + /// Revoke an account's PII encryption access. Admin only. + pub fn revoke_access(env: Env, account: Address) { + Self::require_admin(&env); + env.storage() + .persistent() + .remove(&DataKey::Authorized(account.clone())); + env.events() + .publish((Symbol::new(&env, "access_revoked"),), account); + } + + /// Whether `account` may access PII encryption (admin or granted). + pub fn is_authorized(env: Env, account: Address) -> bool { + Self::is_authorized_internal(&env, &account) + } + + // ── Data export ────────────────────────────────────────────────────────────── + + /// Re-encrypt an existing record under the current active key. Used for + /// secure data export and for migrating ciphertext onto a rotated key. + /// Caller must be authorized. The plaintext never leaves the contract. + pub fn export_encrypted(env: Env, caller: Address, encrypted: EncryptedData) -> EncryptedData { + caller.require_auth(); + Self::require_authorized(&env, &caller); + + // Decrypt under the original key version (verifying integrity)... + let old_key = Self::load_key(&env, encrypted.key_version); + let plaintext = + xor_crypt(&env, &old_key.key_material, &encrypted.nonce, &encrypted.ciphertext); + let check = compute_mac(&env, &old_key.key_material, &encrypted.nonce, &plaintext); + assert!( + check == encrypted.mac, + "MAC verification failed: data tampered or wrong key" + ); + + // ...and re-encrypt under the current active key with a fresh nonce. + let version: u32 = env + .storage() + .instance() + .get(&DataKey::CurrentKeyVersion) + .expect("Not initialized"); + let key = Self::load_key(&env, version); + let nonce = Self::next_nonce(&env, &key.key_material); + let ciphertext = xor_crypt(&env, &key.key_material, &nonce, &plaintext); + let mac = compute_mac(&env, &key.key_material, &nonce, &plaintext); + + EncryptedData { + key_version: version, + nonce, + ciphertext, + mac, + } + } +} From 034f5e648644da3b3a7b249cbf46adadfaeed23a Mon Sep 17 00:00:00 2001 From: shaaibu7 Date: Sun, 31 May 2026 15:39:24 +0100 Subject: [PATCH 3/4] feat(load-tests): add k6 reporting, baselines, per-endpoint metrics & CI matrix Build out automated load testing on top of the existing k6 scenarios: - Per-endpoint custom metrics (endpoint_latency/errors/requests, tagged) so reports attribute latency and errors to a specific operation - Report generation (utils/summary.js handleSummary): writes reports/summary.{json,md,html} plus a stdout summary with a slowest-first per-endpoint breakdown for bottleneck identification - Performance baseline (baseline.json) + comparison (utils/baseline.js) that flags metrics exceeding baseline beyond a tolerance, embedded in the report - Per-endpoint latency + error thresholds in config/options.js (CI gate) - contract load scenario wired into run.js (execute_payment + charge_subscription) - CI load-test job runs a scenario matrix (subscription/billing/contract), fails on threshold breach, and uploads the report as an artifact - npm scripts per scenario; SCALABILITY.md bottleneck guide; load-tests/README.md - gitignore generated reports but keep the directory --- .github/workflows/ci.yml | 30 ++++- .gitignore | 4 + load-tests/README.md | 71 ++++++++++ load-tests/SCALABILITY.md | 51 ++++++++ load-tests/api/subscription.test.js | 21 ++- load-tests/baseline.json | 35 +++++ load-tests/config/options.js | 7 + load-tests/contracts/contractLoad.test.js | 31 ++++- load-tests/reports/.gitkeep | 0 load-tests/run.js | 7 + load-tests/utils/baseline.js | 91 +++++++++++++ load-tests/utils/helpers.js | 34 ++++- load-tests/utils/summary.js | 151 ++++++++++++++++++++++ package.json | 4 + 14 files changed, 517 insertions(+), 20 deletions(-) create mode 100644 load-tests/README.md create mode 100644 load-tests/SCALABILITY.md create mode 100644 load-tests/baseline.json create mode 100644 load-tests/reports/.gitkeep create mode 100644 load-tests/utils/baseline.js create mode 100644 load-tests/utils/summary.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f05e05..5611edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -339,15 +339,41 @@ jobs: load-test: name: k6 Load Test runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + scenario: [subscription, billing, contract] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Run k6 Load Test + - name: Prepare reports directory + run: mkdir -p load-tests/reports + + # k6 exits non-zero when a threshold in config/options.js is breached, + # which fails this job — that is the CI gate on performance regressions. + - name: Run k6 Load Test (${{ matrix.scenario }}) uses: grafana/k6-action@v0.5.1 with: filename: load-tests/run.js - flags: --env SCENARIO=subscription + flags: --env SCENARIO=${{ matrix.scenario }} --quiet + + - name: Rename report for this scenario + if: always() + run: | + for ext in json md html; do + if [ -f "load-tests/reports/summary.$ext" ]; then + mv "load-tests/reports/summary.$ext" "load-tests/reports/${{ matrix.scenario }}.$ext" + fi + done + + - name: Upload load test report + if: always() + uses: actions/upload-artifact@v4 + with: + name: load-test-report-${{ matrix.scenario }} + path: load-tests/reports/ + if-no-files-found: ignore # ───────────────────────────────────────────────────────── # Bundle Size Monitoring diff --git a/.gitignore b/.gitignore index 76dacb1..fc097f2 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,10 @@ logs/ coverage/ .nyc_output/ +# Load test reports (generated) — keep the dir so k6 can write into it +load-tests/reports/* +!load-tests/reports/.gitkeep + # VS Code .vscode/settings.json !.vscode/extensions.json diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 0000000..c692621 --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,71 @@ +# SubTrackr Load Tests (k6) + +Automated load testing for the SubTrackr API and (simulated) contract calls +using [k6](https://k6.io). Covers critical API endpoints, a contract load +simulation, CI-integrated thresholds, performance baselines, and report +generation. + +## Layout + +``` +load-tests/ +├── run.js # entrypoint; dispatches scenarios + handleSummary +├── baseline.json # performance baseline for regression detection +├── config/options.js # load profiles + thresholds (incl. per-endpoint) +├── api/subscription.test.js # API endpoint requests +├── contracts/contractLoad.test.js # Soroban-simulated contract calls +├── scenarios/ # subscriptionFlow / billingCycle / userLoad +├── utils/helpers.js # request helpers + per-endpoint metrics +├── utils/baseline.js # baseline comparison +├── utils/summary.js # report generation (json/md/html + stdout) +├── reports/ # generated reports (git-ignored) +├── SCALABILITY.md # bottleneck identification guide +└── README.md +``` + +## Running + +```bash +# Default ramping subscription flow against the default BASE_URL +npm run load:test + +# Pick a scenario and target +k6 run load-tests/run.js --env SCENARIO=billing --env BASE_URL=https://staging.api.subtrackr.com + +# Convenience scripts +npm run load:test:subscription +npm run load:test:billing +npm run load:test:user +npm run load:test:contract +``` + +`SCENARIO` ∈ `subscription` (default) | `billing` | `user` | `contract`. +Env: `BASE_URL`, `API_KEY`. + +## Reports + +Each run writes to `load-tests/reports/`: + +- `summary.json` — raw k6 metrics (for trend tracking / tooling) +- `summary.md` — human-readable report with baseline diff +- `summary.html` — rich report uploaded as a CI artifact + +## Thresholds & CI + +`config/options.js` defines pass/fail thresholds (overall p95 latency, error +rate, and per-endpoint latency budgets). k6 exits non-zero when a threshold is +breached, so the CI `load-test` job fails the build on a regression. The job +also uploads the HTML/MD/JSON report as an artifact. + +## Performance baseline + +`baseline.json` holds the expected metric values and a `tolerancePct`. After a +run, `utils/baseline.js` compares results and flags any metric that exceeds +baseline by more than the tolerance (shown in the report and stdout). + +**Updating the baseline:** only after an intentional, verified performance +change. Run the relevant scenario against a representative environment, confirm +the new numbers are expected, and edit `baseline.json` in the same PR with a +note explaining the change. Never update it just to silence a regression. + +See [SCALABILITY.md](./SCALABILITY.md) for bottleneck identification. diff --git a/load-tests/SCALABILITY.md b/load-tests/SCALABILITY.md new file mode 100644 index 0000000..0fe1b5c --- /dev/null +++ b/load-tests/SCALABILITY.md @@ -0,0 +1,51 @@ +# Scalability & Bottleneck Identification + +How to read a load-test run and find the scaling limit. + +## Where the numbers come from + +Every run writes `load-tests/reports/summary.{json,md,html}` and prints a stdout +summary. The report contains: + +- **Overall latency** (avg / p95 / p99 / max) and **error rate**. +- **Per-endpoint latency** (`endpoint_latency`, tagged by `endpoint`), sorted + slowest-first. The top row is the primary bottleneck for that load profile. +- **Threshold pass/fail** — each budget in `config/options.js`. +- **Baseline comparison** — measured vs `baseline.json` with a Δ% and regression + flag. + +## Identifying the bottleneck + +1. **Find the saturation point.** Run the ramping profile (`subscription`/ + default). Watch where p95 latency starts climbing faster than the request + rate and where `http_req_failed` first becomes non-zero. That VU level is the + approximate capacity ceiling. +2. **Attribute it to an endpoint.** Open the per-endpoint table. The endpoint + with the highest p95 under load — and the first to breach its budget — is the + bottleneck. Contract endpoints (`contract_*`) are expected to be slower; only + compare them against their own (higher) budgets. +3. **Classify the limit:** + - latency rises but errors stay ~0 → compute / DB / dependency saturation. + - errors spike (timeouts, 5xx) at a VU threshold → connection pool, rate + limiter, or downstream capacity. + - latency flat but throughput plateaus → a hard concurrency cap upstream. +4. **Confirm with the burst profile** (`billing`) to see behaviour under a sudden + spike, and the **sustained profile** (`user`) for soak/leak behaviour over + 5 minutes. + +## Load profiles + +| Profile (`SCENARIO`) | Shape | Purpose | +|---|---|---| +| `subscription` (default) | ramp 0→50→0 | Capacity / saturation point of the full flow | +| `billing` | burst spike to 200 | Resilience to traffic spikes (automated billing) | +| `user` | sustained 100 VUs / 5m | Soak test, memory/connection leaks | +| `contract` | ramp | On-chain (Soroban-simulated) call latency in isolation | + +## Acting on findings + +- Record the saturation VU level and the limiting endpoint in the PR. +- If a fix improves things, re-run and update `baseline.json` deliberately + (see README). If it regresses, the baseline check fails and the report shows + the Δ%. +- File follow-ups for any endpoint that breaches its budget at the target load. diff --git a/load-tests/api/subscription.test.js b/load-tests/api/subscription.test.js index d5d125d..cdd69e1 100644 --- a/load-tests/api/subscription.test.js +++ b/load-tests/api/subscription.test.js @@ -9,19 +9,28 @@ import { export function createSubscription() { const payload = generateSubscriptionData(); - const res = http.post(`${BASE_URL}/subscriptions`, payload, { headers: commonHeaders }); - handleResponse(res, 201); + const res = http.post(`${BASE_URL}/subscriptions`, payload, { + headers: commonHeaders, + tags: { endpoint: 'create_subscription' }, + }); + handleResponse(res, 201, 'create_subscription'); return res.json(); } export function getSubscriptions() { - const res = http.get(`${BASE_URL}/subscriptions`, { headers: commonHeaders }); - handleResponse(res, 200); + const res = http.get(`${BASE_URL}/subscriptions`, { + headers: commonHeaders, + tags: { endpoint: 'list_subscriptions' }, + }); + handleResponse(res, 200, 'list_subscriptions'); } export function cancelSubscription(id) { - const res = http.del(`${BASE_URL}/subscriptions/${id}`, null, { headers: commonHeaders }); - handleResponse(res, 204); + const res = http.del(`${BASE_URL}/subscriptions/${id}`, null, { + headers: commonHeaders, + tags: { endpoint: 'cancel_subscription' }, + }); + handleResponse(res, 204, 'cancel_subscription'); } export default function () { diff --git a/load-tests/baseline.json b/load-tests/baseline.json new file mode 100644 index 0000000..3790f78 --- /dev/null +++ b/load-tests/baseline.json @@ -0,0 +1,35 @@ +{ + "_comment": "Performance baseline for SubTrackr load tests. Update deliberately after a verified, intentional performance change (see load-tests/README.md). The baseline checker (utils/baseline.js) flags a regression when a measured metric exceeds baseline by more than `tolerancePct`.", + "tolerancePct": 15, + "metrics": { + "http_req_duration": { + "p95": 500, + "p99": 900, + "avg": 200, + "unit": "ms", + "description": "Overall HTTP request latency" + }, + "http_req_failed": { + "rate": 0.01, + "unit": "ratio", + "description": "Overall HTTP error rate" + }, + "endpoint_latency": { + "p95": 500, + "unit": "ms", + "description": "Per-endpoint latency (aggregate)" + }, + "iteration_duration": { + "p95": 4000, + "unit": "ms", + "description": "Full scenario iteration time" + } + }, + "endpoints": { + "create_subscription": { "p95": 600, "unit": "ms" }, + "list_subscriptions": { "p95": 400, "unit": "ms" }, + "cancel_subscription": { "p95": 400, "unit": "ms" }, + "contract_execute_payment": { "p95": 1500, "unit": "ms" }, + "contract_charge_subscription": { "p95": 1500, "unit": "ms" } + } +} diff --git a/load-tests/config/options.js b/load-tests/config/options.js index 61d2054..31a5943 100644 --- a/load-tests/config/options.js +++ b/load-tests/config/options.js @@ -7,6 +7,13 @@ export const options = { thresholds: { http_req_duration: ['p(95)<500'], // 95% of requests must be below 500ms http_req_failed: ['rate<0.01'], // Error rate should be less than 1% + endpoint_errors: ['rate<0.01'], // Per-endpoint error rate + // Per-endpoint latency budgets (drive bottleneck detection in CI). + 'endpoint_latency{endpoint:create_subscription}': ['p(95)<600'], + 'endpoint_latency{endpoint:list_subscriptions}': ['p(95)<400'], + 'endpoint_latency{endpoint:cancel_subscription}': ['p(95)<400'], + 'endpoint_latency{endpoint:contract_execute_payment}': ['p(95)<1500'], + 'endpoint_latency{endpoint:contract_charge_subscription}': ['p(95)<1500'], }, }; diff --git a/load-tests/contracts/contractLoad.test.js b/load-tests/contracts/contractLoad.test.js index 2c430b1..3add720 100644 --- a/load-tests/contracts/contractLoad.test.js +++ b/load-tests/contracts/contractLoad.test.js @@ -1,8 +1,10 @@ import http from 'k6/http'; -import { sleep, check } from 'k6'; -import { BASE_URL, commonHeaders } from '../utils/helpers.js'; +import { sleep } from 'k6'; +import { BASE_URL, commonHeaders, handleResponse } from '../utils/helpers.js'; -// Mocking Soroban interaction through a backend proxy or simulator endpoint +// Mocking Soroban interaction through a backend proxy or simulator endpoint. +// Contract calls are tagged separately so the report can distinguish on-chain +// latency (which is expected to be higher) from plain API latency. export function simulateContractPayment() { const payload = JSON.stringify({ contractId: 'CACX...123', @@ -12,15 +14,30 @@ export function simulateContractPayment() { const res = http.post(`${BASE_URL}/contracts/simulate-payment`, payload, { headers: Object.assign({}, commonHeaders, { 'X-Soroban-Simulation': 'true' }), + tags: { endpoint: 'contract_execute_payment' }, }); - check(res, { - 'contract success': (r) => r.status === 200, - 'latency within bounds': (r) => r.timings.duration < 1500, // Contract interactions can be slower + return handleResponse(res, 200, 'contract_execute_payment'); +} + +export function simulateContractCharge() { + const payload = JSON.stringify({ + contractId: 'CACX...123', + method: 'charge_subscription', + args: { subscriptionId: Math.floor(Math.random() * 100000) }, }); + + const res = http.post(`${BASE_URL}/contracts/simulate-charge`, payload, { + headers: Object.assign({}, commonHeaders, { 'X-Soroban-Simulation': 'true' }), + tags: { endpoint: 'contract_charge_subscription' }, + }); + + return handleResponse(res, 200, 'contract_charge_subscription'); } export default function () { simulateContractPayment(); - sleep(2); + sleep(1); + simulateContractCharge(); + sleep(1); } diff --git a/load-tests/reports/.gitkeep b/load-tests/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/load-tests/run.js b/load-tests/run.js index affc378..ecc5b12 100644 --- a/load-tests/run.js +++ b/load-tests/run.js @@ -2,15 +2,22 @@ import { options as defaultOptions } from './config/options.js'; import subscriptionFlow from './scenarios/subscriptionFlow.js'; import billingCycle from './scenarios/billingCycle.js'; import userLoad from './scenarios/userLoad.js'; +import contractLoad from './contracts/contractLoad.test.js'; +import { handleSummary } from './utils/summary.js'; const scenarios = { subscription: subscriptionFlow, billing: billingCycle, user: userLoad, + contract: contractLoad, }; export const options = defaultOptions; +// Generates load-tests/reports/{summary.json,summary.md,summary.html} and a +// stdout summary with baseline comparison at the end of every run. +export { handleSummary }; + export default function () { const scenarioName = __ENV.SCENARIO || 'subscription'; const scenario = scenarios[scenarioName] || scenarios.subscription; diff --git a/load-tests/utils/baseline.js b/load-tests/utils/baseline.js new file mode 100644 index 0000000..bc1cd94 --- /dev/null +++ b/load-tests/utils/baseline.js @@ -0,0 +1,91 @@ +// Baseline comparison for k6 results. +// +// Compares the end-of-test metric summary against load-tests/baseline.json and +// flags regressions (a measured value worse than baseline by more than the +// configured tolerance). Returned data is embedded in the generated report and +// printed to stdout so CI surfaces regressions even when raw thresholds pass. + +import baseline from '../baseline.json'; + +function pct(measured, base) { + if (base === 0) return measured === 0 ? 0 : 100; + return ((measured - base) / base) * 100; +} + +function metricValue(metric, stat) { + if (!metric || !metric.values) return undefined; + const v = metric.values; + switch (stat) { + case 'p95': + return v['p(95)']; + case 'p99': + return v['p(99)']; + case 'avg': + return v.avg; + case 'rate': + return v.rate; + default: + return v[stat]; + } +} + +/** + * @param {object} data - k6 summary `data` object (data.metrics) + * @returns {{ regressions: Array, comparisons: Array, text: string }} + */ +export function checkBaseline(data) { + const tolerance = baseline.tolerancePct ?? 15; + const comparisons = []; + const regressions = []; + + const considerStat = (name, metricKey, stat, base, unit) => { + const measured = metricValue(data.metrics[metricKey], stat); + if (measured === undefined) return; + const delta = pct(measured, base); + const regressed = delta > tolerance; + const row = { + name: `${name} (${stat})`, + measured: Math.round(measured * 100) / 100, + baseline: base, + deltaPct: Math.round(delta * 10) / 10, + unit: unit || '', + regressed, + }; + comparisons.push(row); + if (regressed) regressions.push(row); + }; + + // Top-level metrics. + for (const [metricKey, spec] of Object.entries(baseline.metrics || {})) { + for (const stat of ['p95', 'p99', 'avg', 'rate']) { + if (spec[stat] !== undefined) { + considerStat(metricKey, metricKey, stat, spec[stat], spec.unit); + } + } + } + + // Per-endpoint sub-metrics (k6 exposes tagged submetrics as + // "endpoint_latency{endpoint:create_subscription}"). + for (const [endpoint, spec] of Object.entries(baseline.endpoints || {})) { + const subKey = `endpoint_latency{endpoint:${endpoint}}`; + if (spec.p95 !== undefined) { + considerStat(`endpoint:${endpoint}`, subKey, 'p95', spec.p95, spec.unit); + } + } + + let text = '\n=== Baseline comparison (tolerance ' + tolerance + '%) ===\n'; + if (comparisons.length === 0) { + text += 'No comparable metrics found in this run.\n'; + } else { + for (const c of comparisons) { + const flag = c.regressed ? 'REGRESSION' : 'ok'; + const sign = c.deltaPct >= 0 ? '+' : ''; + text += ` [${flag}] ${c.name}: ${c.measured}${c.unit} vs baseline ${c.baseline}${c.unit} (${sign}${c.deltaPct}%)\n`; + } + } + if (regressions.length > 0) { + text += `\n${regressions.length} performance regression(s) detected against baseline.\n`; + } + + return { regressions, comparisons, text }; +} diff --git a/load-tests/utils/helpers.js b/load-tests/utils/helpers.js index df75f4d..bc62012 100644 --- a/load-tests/utils/helpers.js +++ b/load-tests/utils/helpers.js @@ -1,4 +1,5 @@ import { check } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; export const BASE_URL = __ENV.BASE_URL || 'https://api.subtrackr.example.com'; @@ -7,6 +8,15 @@ export const commonHeaders = { 'api-key': __ENV.API_KEY || 'default-test-key', }; +// ── Per-endpoint custom metrics ──────────────────────────────────────────── +// Tagged by `endpoint` so the report can attribute latency / error rate to a +// specific operation. This is the raw material for bottleneck identification: +// the endpoint with the highest p95 latency or error rate under load is the +// scalability bottleneck. See load-tests/SCALABILITY.md. +export const endpointLatency = new Trend('endpoint_latency', true); +export const endpointErrors = new Rate('endpoint_errors'); +export const endpointRequests = new Counter('endpoint_requests'); + export function randomString(length) { const charset = 'abcdefghijklmnopqrstuvwxyz0123456789'; let res = ''; @@ -14,11 +24,25 @@ export function randomString(length) { return res; } -export function handleResponse(res, status = 200) { - const success = check(res, { - [`status is ${status}`]: (r) => r.status === status, - 'transaction time < 500ms': (r) => r.timings.duration < 500, - }); +/** + * Validate a response and record per-endpoint metrics. + * @param {object} res - k6 http response + * @param {number} status - expected status code + * @param {string} label - endpoint label used for metric tagging + */ +export function handleResponse(res, status = 200, label = 'unknown') { + const tags = { endpoint: label }; + const success = check( + res, + { + [`status is ${status}`]: (r) => r.status === status, + 'transaction time < 500ms': (r) => r.timings.duration < 500, + }, + tags, + ); + endpointLatency.add(res.timings.duration, tags); + endpointErrors.add(!success, tags); + endpointRequests.add(1, tags); return success; } diff --git a/load-tests/utils/summary.js b/load-tests/utils/summary.js new file mode 100644 index 0000000..68ec46d --- /dev/null +++ b/load-tests/utils/summary.js @@ -0,0 +1,151 @@ +// Report generation for k6 runs. +// +// Exported `handleSummary` is picked up by k6 at the end of a test and writes: +// - load-tests/reports/summary.json (raw metrics, for tooling / trend tracking) +// - load-tests/reports/summary.md (human-readable report + baseline diff) +// - load-tests/reports/summary.html (rich report for CI artifacts) +// plus a concise text summary to stdout. +// +// Self-contained (no remote jslib import) so it works in offline / CI sandboxes. + +import { checkBaseline } from './baseline.js'; + +function fmt(n, digits = 2) { + if (n === undefined || n === null || Number.isNaN(n)) return 'n/a'; + return Number(n).toFixed(digits); +} + +function metric(data, key) { + const m = data.metrics[key]; + return m ? m.values : {}; +} + +function thresholdStatus(data) { + const rows = []; + for (const [name, m] of Object.entries(data.metrics)) { + if (m.thresholds) { + for (const [expr, res] of Object.entries(m.thresholds)) { + const ok = res && res.ok !== false; + rows.push({ name: `${name}: ${expr}`, ok }); + } + } + } + return rows; +} + +function endpointBreakdown(data) { + const rows = []; + for (const [key, m] of Object.entries(data.metrics)) { + if (key.startsWith('endpoint_latency{')) { + const endpoint = key.slice('endpoint_latency{endpoint:'.length, -1); + rows.push({ + endpoint, + avg: m.values.avg, + p95: m.values['p(95)'], + max: m.values.max, + count: m.values.count, + }); + } + } + rows.sort((a, b) => (b.p95 || 0) - (a.p95 || 0)); + return rows; +} + +function textReport(data, baseline) { + const dur = metric(data, 'http_req_duration'); + const failed = metric(data, 'http_req_failed'); + const reqs = metric(data, 'http_reqs'); + const thresholds = thresholdStatus(data); + const failedThresholds = thresholds.filter((t) => !t.ok); + + let out = '\n========== SubTrackr Load Test Summary ==========\n'; + out += `Total requests: ${reqs.count ?? 0} (${fmt(reqs.rate)}/s)\n`; + out += `Latency avg/p95/p99/max: ${fmt(dur.avg)} / ${fmt(dur['p(95)'])} / ${fmt(dur['p(99)'])} / ${fmt(dur.max)} ms\n`; + out += `Error rate: ${fmt((failed.rate ?? 0) * 100)}%\n`; + out += `Thresholds: ${thresholds.length - failedThresholds.length}/${thresholds.length} passed\n`; + + const eps = endpointBreakdown(data); + if (eps.length) { + out += '\nPer-endpoint p95 (slowest first — likely bottleneck at top):\n'; + for (const e of eps) { + out += ` ${e.endpoint.padEnd(30)} p95=${fmt(e.p95)}ms avg=${fmt(e.avg)}ms n=${e.count ?? 0}\n`; + } + } + out += baseline.text; + if (failedThresholds.length) { + out += `\nFAILED THRESHOLDS:\n`; + for (const t of failedThresholds) out += ` - ${t.name}\n`; + } + out += '=================================================\n'; + return out; +} + +function mdReport(data, baseline) { + const dur = metric(data, 'http_req_duration'); + const failed = metric(data, 'http_req_failed'); + const reqs = metric(data, 'http_reqs'); + const eps = endpointBreakdown(data); + const thresholds = thresholdStatus(data); + + let md = `# SubTrackr Load Test Report\n\n`; + md += `## Summary\n\n`; + md += `| Metric | Value |\n|---|---|\n`; + md += `| Total requests | ${reqs.count ?? 0} (${fmt(reqs.rate)}/s) |\n`; + md += `| Latency avg | ${fmt(dur.avg)} ms |\n`; + md += `| Latency p95 | ${fmt(dur['p(95)'])} ms |\n`; + md += `| Latency p99 | ${fmt(dur['p(99)'])} ms |\n`; + md += `| Latency max | ${fmt(dur.max)} ms |\n`; + md += `| Error rate | ${fmt((failed.rate ?? 0) * 100)}% |\n\n`; + + md += `## Thresholds\n\n`; + if (thresholds.length === 0) md += `_No thresholds configured._\n\n`; + else { + md += `| Threshold | Status |\n|---|---|\n`; + for (const t of thresholds) md += `| ${t.name} | ${t.ok ? '✅ pass' : '❌ FAIL'} |\n`; + md += `\n`; + } + + if (eps.length) { + md += `## Per-endpoint latency (slowest first)\n\n`; + md += `| Endpoint | p95 (ms) | avg (ms) | max (ms) | requests |\n|---|---|---|---|---|\n`; + for (const e of eps) { + md += `| ${e.endpoint} | ${fmt(e.p95)} | ${fmt(e.avg)} | ${fmt(e.max)} | ${e.count ?? 0} |\n`; + } + md += `\n> The endpoint at the top of this table is the primary scalability bottleneck under this load profile. See [SCALABILITY.md](../SCALABILITY.md).\n\n`; + } + + md += `## Baseline comparison\n\n`; + if (baseline.comparisons.length === 0) md += `_No comparable baseline metrics._\n\n`; + else { + md += `| Metric | Measured | Baseline | Δ% | Status |\n|---|---|---|---|---|\n`; + for (const c of baseline.comparisons) { + md += `| ${c.name} | ${c.measured}${c.unit} | ${c.baseline}${c.unit} | ${c.deltaPct >= 0 ? '+' : ''}${c.deltaPct}% | ${c.regressed ? '❌ regression' : '✅ ok'} |\n`; + } + md += `\n`; + } + if (baseline.regressions.length > 0) { + md += `> ⚠️ **${baseline.regressions.length} regression(s)** detected against the performance baseline.\n`; + } + return md; +} + +function htmlReport(data, baseline) { + const md = mdReport(data, baseline) + .replace(/&/g, '&') + .replace(//g, '>'); + return `SubTrackr Load Test Report + +
${md}
`; +} + +export function handleSummary(data) { + const baseline = checkBaseline(data); + return { + stdout: textReport(data, baseline), + 'load-tests/reports/summary.json': JSON.stringify(data, null, 2), + 'load-tests/reports/summary.md': mdReport(data, baseline), + 'load-tests/reports/summary.html': htmlReport(data, baseline), + }; +} diff --git a/package.json b/package.json index 9615bc8..00be264 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,10 @@ "ci": "npm run lint && npm run contracts:codegen:check && npx tsc --noEmit && npm run test && npm run contracts:test && npm run contracts:fmt && npm run contracts:clippy", "prepare": "husky", "load:test": "k6 run load-tests/run.js", + "load:test:subscription": "k6 run load-tests/run.js --env SCENARIO=subscription", + "load:test:billing": "k6 run load-tests/run.js --env SCENARIO=billing", + "load:test:user": "k6 run load-tests/run.js --env SCENARIO=user", + "load:test:contract": "k6 run load-tests/run.js --env SCENARIO=contract", "e2e:build-ios": "detox build -c ios.sim.release", "e2e:test-ios": "detox test -c ios.sim.release", "e2e:test-ios:parallel": "detox test -c ios.sim.release --workers 2", From eb8954757f9743ab0cd22845c80c75b1463bf2bf Mon Sep 17 00:00:00 2001 From: shaaibu7 Date: Sun, 31 May 2026 15:50:47 +0100 Subject: [PATCH 4/4] feat(webhook): implement real HMAC-SHA256 delivery signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook contract module, RN management UI (src/screens/WebhookSettingsScreen, wired into AppNavigator) and store (src/store/webhookStore) already exist; the one unmet acceptance criterion was signature verification for security, which was stubbed as a hardcoded 'sample-signature'. - add src/utils/webhookSignature.ts: pure-JS HMAC-SHA256 signing/verification (sha256= convention, à la Stripe/GitHub), constant-time verify, payload serialization, and secret generation - sign each delivery payload with the webhook secret in sendTestEvent instead of the placeholder signature - auto-generate a signing secret at registration when none is supplied, so every webhook's deliveries are verifiable Note: the issue listed app/stores/webhookStore.ts and app/screens/WebhookSettingsScreen.tsx, but the feature is already implemented under src/; enhanced the existing wired implementation rather than adding unwired duplicates. --- src/store/webhookStore.ts | 89 ++++++++++-------- src/utils/webhookSignature.ts | 165 ++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 38 deletions(-) create mode 100644 src/utils/webhookSignature.ts diff --git a/src/store/webhookStore.ts b/src/store/webhookStore.ts index f1f949a..abb0bbf 100644 --- a/src/store/webhookStore.ts +++ b/src/store/webhookStore.ts @@ -10,6 +10,11 @@ import { WebhookRetryPolicy, } from '../types/webhook'; import { BillingCycle } from '../types/subscription'; +import { + generateWebhookSecret, + serializeWebhookPayload, + signWebhookPayload, +} from '../utils/webhookSignature'; const STORAGE_KEY = 'subtrackr-webhooks'; const DEFAULT_RETRY_POLICY: WebhookRetryPolicy = { @@ -100,6 +105,9 @@ export const useWebhookStore = create()( registerWebhook: async (input) => { const webhook: WebhookConfig = { ...input, + // Ensure every webhook has a signing secret so deliveries are + // verifiable; generate one if the caller did not supply it. + secretKey: input.secretKey?.trim() ? input.secretKey : generateWebhookSecret(), id: createId('whk'), createdAt: now(), updatedAt: now(), @@ -203,53 +211,58 @@ export const useWebhookStore = create()( sendTestEvent: async (webhookId, eventType = 'subscription.created') => { const webhook = get().webhooks.find((entry) => entry.id === webhookId); if (!webhook) throw new Error(`Webhook ${webhookId} not found`); - return get().recordDelivery({ + const eventId = createId('evt'); + const payload = { + id: eventId, webhookId, - eventId: createId('evt'), eventType, - url: webhook.url, - payload: { - id: createId('evt'), - webhookId, - eventType, - occurredAt: now(), + occurredAt: now(), + merchantId: webhook.merchantId, + previousStatus: 'none', + currentStatus: 'active', + payloadVersion: 1, + subscription: { + id: 'sample_subscription', + planId: 'sample_plan', + subscriberId: 'sample_customer', + status: 'active', + startedAt: now(), + lastChargedAt: now(), + nextChargeAt: now() + 2_592_000_000, + totalPaid: 49, + totalGasSpent: 0, + chargeCount: 1, + pausedAt: 0, + pauseDuration: 0, + refundRequestedAmount: 0, + }, + plan: { + id: 'sample_plan', merchantId: webhook.merchantId, - previousStatus: 'none', - currentStatus: 'active', - payloadVersion: 1, - subscription: { - id: 'sample_subscription', - planId: 'sample_plan', - subscriberId: 'sample_customer', - status: 'active', - startedAt: now(), - lastChargedAt: now(), - nextChargeAt: now() + 2_592_000_000, - totalPaid: 49, - totalGasSpent: 0, - chargeCount: 1, - pausedAt: 0, - pauseDuration: 0, - refundRequestedAmount: 0, - }, - plan: { - id: 'sample_plan', - merchantId: webhook.merchantId, - name: 'Sample plan', - price: 49, - token: 'USD', - interval: BillingCycle.MONTHLY, - active: true, - subscriberCount: 1, - createdAt: now(), - }, + name: 'Sample plan', + price: 49, + token: 'USD', + interval: BillingCycle.MONTHLY, + active: true, + subscriberCount: 1, + createdAt: now(), }, + }; + // Sign the exact serialized payload with the webhook secret so the + // receiver can verify authenticity/integrity (HMAC-SHA256). + const signature = signWebhookPayload(serializeWebhookPayload(payload), webhook.secretKey); + return get().recordDelivery({ + webhookId, + eventId, + eventType, + url: webhook.url, + payload, status: 'delivered', attempts: 1, maxAttempts: webhook.retryPolicy.maxRetries, deliveredAt: now(), responseCode: 200, - signature: 'sample-signature', + signature, idempotencyKey: createId('idem'), latencyMs: 120, }); diff --git a/src/utils/webhookSignature.ts b/src/utils/webhookSignature.ts new file mode 100644 index 0000000..78f18da --- /dev/null +++ b/src/utils/webhookSignature.ts @@ -0,0 +1,165 @@ +// ════════════════════════════════════════════════════════════════ +// WEBHOOK SIGNATURES - HMAC-SHA256 signing & verification +// ════════════════════════════════════════════════════════════════ +// +// Webhook deliveries are signed with the webhook's secret key so receivers can +// verify authenticity and integrity. The signature is `sha256=` over +// the exact serialized payload bytes — the same convention used by Stripe and +// GitHub. +// +// Pure-JS SHA-256 (no native dependency) so signing works in React Native and +// is deterministic across platforms. Receivers reproduce the HMAC over the raw +// request body with the shared secret and compare in constant time. + +const SHA_K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n)); + +function sha256(msg: Uint8Array): Uint8Array { + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + const bitLen = msg.length * 8; + const withOne = msg.length + 1; + const totalLen = withOne + ((56 - (withOne % 64) + 64) % 64) + 8; + const padded = new Uint8Array(totalLen); + padded.set(msg); + padded[msg.length] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(totalLen - 4, bitLen >>> 0, false); + dv.setUint32(totalLen - 8, Math.floor(bitLen / 0x100000000) >>> 0, false); + + const w = new Uint32Array(64); + for (let off = 0; off < totalLen; off += 64) { + for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let a = h[0]; + let b = h[1]; + let c = h[2]; + let d = h[3]; + let e = h[4]; + let f = h[5]; + let g = h[6]; + let hh = h[7]; + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + SHA_K[i] + w[i]) >>> 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + const out = new Uint8Array(32); + const odv = new DataView(out.buffer); + for (let i = 0; i < 8; i++) odv.setUint32(i * 4, h[i], false); + return out; +} + +function utf8(str: string): Uint8Array { + if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str); + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + if (c < 0x80) bytes.push(c); + else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + return new Uint8Array(bytes); +} + +function toHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, '0'); + return s; +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a); + out.set(b, a.length); + return out; +} + +function hmacSha256(key: Uint8Array, msg: Uint8Array): Uint8Array { + const block = 64; + let k = key; + if (k.length > block) k = sha256(k); + const padded = new Uint8Array(block); + padded.set(k); + const ipad = new Uint8Array(block); + const opad = new Uint8Array(block); + for (let i = 0; i < block; i++) { + ipad[i] = padded[i] ^ 0x36; + opad[i] = padded[i] ^ 0x5c; + } + return sha256(concat(opad, sha256(concat(ipad, msg)))); +} + +/** Serialize a payload to the exact bytes that get signed/sent. */ +export function serializeWebhookPayload(payload: unknown): string { + return JSON.stringify(payload); +} + +/** Sign a serialized payload with the webhook secret. Returns `sha256=`. */ +export function signWebhookPayload(serializedPayload: string, secret: string): string { + return `sha256=${toHex(hmacSha256(utf8(secret), utf8(serializedPayload)))}`; +} + +/** Convenience: sign a payload object directly. */ +export function signWebhookEvent(payload: unknown, secret: string): string { + return signWebhookPayload(serializeWebhookPayload(payload), secret); +} + +/** Constant-time verification of a webhook signature against a payload. */ +export function verifyWebhookSignature( + serializedPayload: string, + signature: string, + secret: string, +): boolean { + const expected = signWebhookPayload(serializedPayload, secret); + if (expected.length !== signature.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i); + } + return diff === 0; +} + +/** Generate a webhook signing secret. */ +export function generateWebhookSecret(): string { + const out = new Uint8Array(24); + const g = (globalThis as { crypto?: Crypto }).crypto; + if (g && typeof g.getRandomValues === 'function') g.getRandomValues(out); + else for (let i = 0; i < out.length; i++) out[i] = Math.floor(Math.random() * 256); + return `whsec_${toHex(out)}`; +}