code wiki / (root) / nx_rate_limit.nx

nx_rate_limit.nx source

↩ module page · 221 lines · 8614 B

1// nx_rate_limit.nx -- token-bucket rate limiter for brute-force resistance. 2// 3// Per docs/SECURITY_POSTURE.md principle P3: every API that 4// verifies a signature, unseals a capability, or processes auth 5// must rate-limit retries from the same source. Without this, 6// an attacker with unlimited tries can exhaust the search space 7// of any K-bit secret in 2^K / rate seconds, and current commodity 8// hardware lets that be very fast. 9// 10// Token bucket semantics: 11// - Each (operation, identity) pair has a bucket of `capacity` 12// tokens, refilled at `refill_per_sec` tokens/second. 13// - Each `consume(op, id)` call removes 1 token; if bucket is 14// empty, the call fails (returns -EAGAIN). 15// - State is persistent across restarts (caller stores the 16// serialized bucket; we provide load/save). 17// 18// Lockout escalation: 19// - After `lockout_threshold` consecutive failures from same 20// identity: lockout_duration grows exponentially (1s, 1min, 21// 1hr, 1day, permanent). 22// - Permanent lockout requires explicit `nx_rl_admin_unlock` 23// by an authority key (multi-person if K-of-N policy is 24// enabled; single-key fallback for early dev). 25// 26// Logging: every failure emits a signed log entry through 27// nx_attest (when shipped) so external witnesses can observe 28// abuse patterns. 29// 30// Pairs with: nx_atom (per-bucket CAS), nx_attest (failure logs), 31// nx_pcc (proof of "bucket invariant: tokens in [0, capacity]"). 32 33// nx_safety_envelope: 34// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 35// sil_target: SIL1 36// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 37// verdict: NOT_YET_EVALUATED 38 39import "syscalls.nx" 40const NX_MAGIC_1000000: i64 = 1000000 41const NX_MAGIC_3600: i64 = 3600 42const NX_MAGIC_86400: i64 = 86400 43const NX_MAGIC_60000: i64 = 60000 44const NX_MAGIC_86400000: i64 = 86400000 45 46// Bucket state. Caller may have many buckets, one per 47// (operation, identity) pair. 48struct NxRlBucket { 49 tokens: i64, // current count, in [0, capacity] 50 capacity: i64, // max tokens 51 refill_per_sec: i64, // refill rate 52 last_refill_ms: i64, // monotonic time of last refill 53 consecutive_fail: i64, // count of failures since last success 54 lockout_until_ms: i64, // 0 = not locked; >0 = unlock time 55 lockout_count: i64, // for exponential backoff 56} 57 58const NX_RL_BUCKET_BYTES: i64 = 56 59 60// Errors 61const NX_RL_OK: i64 = 0 62const NX_RL_THROTTLED: i64 = 0xFFFFFFFFFFFFFFF5 // -11 / -EAGAIN 63const NX_RL_LOCKED_OUT: i64 = 0xFFFFFFFFFFFFFFE9 // -23 / temporary lockout 64const NX_RL_PERMANENT_LOCK: i64 = 0xFFFFFFFFFFFFFFE8 // -24 / permanent 65 66// Defaults tuned for "human-paced" auth: 5 failures per minute, 67// then 1-minute lockout, escalating up to 1 day, then permanent 68// lockout after total of 100 failures. 69const NX_RL_DEFAULT_CAPACITY: i64 = 5 70const NX_RL_DEFAULT_REFILL_PER_MIN: i64 = 5 71const NX_RL_DEFAULT_LOCKOUT_THRESHOLD: i64 = 5 72const NX_RL_PERMANENT_AT: i64 = 100 73 74// Get monotonic-clock millis via clock_gettime(CLOCK_MONOTONIC). 75func nx_rl_now_ms() -> i64 { 76 let ts_raw: *u8 = sys_mmap(32) 77 let ts: *i64 = ts_raw as *i64 78 __syscall(113, 1, ts_raw as i64, 0, 0, 0, 0) // CLOCK_MONOTONIC 79 return ts[0] * 1000 + ts[1] / NX_MAGIC_1000000 80} 81 82// Construct a bucket with given parameters. 83func nx_rl_new(capacity: i64, refill_per_sec: i64) -> *NxRlBucket { 84 let raw: *u8 = sys_mmap(NX_RL_BUCKET_BYTES) 85 let b: *NxRlBucket = raw as *NxRlBucket 86 b.tokens = capacity 87 b.capacity = capacity 88 b.refill_per_sec = refill_per_sec 89 b.last_refill_ms = nx_rl_now_ms() 90 b.consecutive_fail = 0 91 b.lockout_until_ms = 0 92 b.lockout_count = 0 93 return b 94} 95 96// Refill the bucket based on elapsed time since last refill. 97func nx_rl_refill(b: *NxRlBucket) -> i64 { 98 let now: i64 = nx_rl_now_ms() 99 let elapsed_ms: i64 = now - b.last_refill_ms 100 if elapsed_ms <= 0 { return 0 } 101 let new_tokens: i64 = (elapsed_ms * b.refill_per_sec) / 1000 102 if new_tokens <= 0 { return 0 } 103 b.tokens = b.tokens + new_tokens 104 if b.tokens > b.capacity { b.tokens = b.capacity } 105 b.last_refill_ms = now 106 return 0 107} 108 109// Try to consume one token. Returns NX_RL_OK on success or 110// negative error code on failure / lockout. Use this BEFORE every 111// cryptographic verification to prevent brute force. 112func nx_rl_try_consume(b: *NxRlBucket) -> i64 { 113 // Permanent lockout: only an admin unlock can clear it. 114 if b.lockout_count >= NX_RL_PERMANENT_AT { 115 return NX_RL_PERMANENT_LOCK 116 } 117 let now: i64 = nx_rl_now_ms() 118 if b.lockout_until_ms > now { 119 return NX_RL_LOCKED_OUT 120 } 121 nx_rl_refill(b) 122 if b.tokens <= 0 { 123 return NX_RL_THROTTLED 124 } 125 b.tokens = b.tokens - 1 126 return NX_RL_OK 127} 128 129// Exponential backoff schedule. Returns -1 to signal "go permanent". 130func nx_rl_backoff_ms(count: i64) -> i64 { 131 if count == 1 { return 1000 } // 1 second 132 if count == 2 { return 60 * 1000 } // 1 minute 133 if count == 3 { return NX_MAGIC_3600 * 1000 } // 1 hour 134 if count == 4 { return NX_MAGIC_86400 * 1000 } // 1 day 135 return 0 - 1 // permanent 136} 137 138// Caller reports the outcome. On success: reset failure counter. 139// On failure: bump failure counter; if threshold exceeded, set 140// exponentially-backing-off lockout. 141func nx_rl_record(b: *NxRlBucket, success: i64) -> i64 { 142 if success == 1 { 143 b.consecutive_fail = 0 144 return 0 145 } 146 b.consecutive_fail = b.consecutive_fail + 1 147 if b.consecutive_fail < NX_RL_DEFAULT_LOCKOUT_THRESHOLD { 148 return 0 149 } 150 // Exponential backoff: 1s, 1min, 1hr, 1day, then permanent. 151 b.lockout_count = b.lockout_count + 1 152 let backoff_ms: i64 = nx_rl_backoff_ms(b.lockout_count) 153 if backoff_ms < 0 { 154 // Sentinel for permanent lockout 155 b.lockout_count = NX_RL_PERMANENT_AT 156 return NX_RL_PERMANENT_LOCK 157 } 158 b.lockout_until_ms = nx_rl_now_ms() + backoff_ms 159 return NX_RL_LOCKED_OUT 160} 161 162// Admin override. Caller must already hold an admin capability; 163// this primitive does not check (that's the caller's job in 164// nx_caps). Resets ALL state. Logged via nx_attest in the 165// integrated build. 166func nx_rl_admin_unlock(b: *NxRlBucket) -> i64 { 167 b.tokens = b.capacity 168 b.consecutive_fail = 0 169 b.lockout_until_ms = 0 170 b.lockout_count = 0 171 b.last_refill_ms = nx_rl_now_ms() 172 return 0 173} 174 175// ---- self-test --------------------------------------------------- 176 177func main() -> i64 { 178 let b: *NxRlBucket = nx_rl_new(3, 1) 179 if b.tokens != 3 { return __syscall(93, 1, 0, 0, 0, 0, 0) } 180 181 // Drain 3 tokens 182 if nx_rl_try_consume(b) != NX_RL_OK { return __syscall(93, 2, 0, 0, 0, 0, 0) } 183 if nx_rl_try_consume(b) != NX_RL_OK { return __syscall(93, 3, 0, 0, 0, 0, 0) } 184 if nx_rl_try_consume(b) != NX_RL_OK { return __syscall(93, 4, 0, 0, 0, 0, 0) } 185 186 // 4th consume should be throttled 187 if nx_rl_try_consume(b) != NX_RL_THROTTLED { return __syscall(93, 5, 0, 0, 0, 0, 0) } 188 189 // Record 5 failures -> lockout at level 1 (1 second) 190 nx_rl_record(b, 0) 191 nx_rl_record(b, 0) 192 nx_rl_record(b, 0) 193 nx_rl_record(b, 0) 194 let r: i64 = nx_rl_record(b, 0) 195 if r != NX_RL_LOCKED_OUT { return __syscall(93, 6, 0, 0, 0, 0, 0) } 196 if b.lockout_count != 1 { return __syscall(93, 7, 0, 0, 0, 0, 0) } 197 198 // Try-consume should now report locked out 199 if nx_rl_try_consume(b) != NX_RL_LOCKED_OUT { return __syscall(93, 8, 0, 0, 0, 0, 0) } 200 201 // Admin unlock clears state 202 nx_rl_admin_unlock(b) 203 if b.lockout_count != 0 { return __syscall(93, 9, 0, 0, 0, 0, 0) } 204 if nx_rl_try_consume(b) != NX_RL_OK { return __syscall(93, 10, 0, 0, 0, 0, 0) } 205 206 // Backoff schedule: count=1 -> 1s, count=2 -> 1min, count=4 -> 1day, count=5 -> permanent 207 if nx_rl_backoff_ms(1) != 1000 { return __syscall(93, 11, 0, 0, 0, 0, 0) } 208 if nx_rl_backoff_ms(2) != NX_MAGIC_60000 { return __syscall(93, 12, 0, 0, 0, 0, 0) } 209 if nx_rl_backoff_ms(4) != NX_MAGIC_86400000 { return __syscall(93, 13, 0, 0, 0, 0, 0) } 210 if nx_rl_backoff_ms(5) != (0 - 1) { return __syscall(93, 14, 0, 0, 0, 0, 0) } 211 212 // Success record clears consecutive_fail 213 let b2: *NxRlBucket = nx_rl_new(10, 10) 214 nx_rl_record(b2, 0) 215 nx_rl_record(b2, 0) 216 if b2.consecutive_fail != 2 { return __syscall(93, 15, 0, 0, 0, 0, 0) } 217 nx_rl_record(b2, 1) 218 if b2.consecutive_fail != 0 { return __syscall(93, 16, 0, 0, 0, 0, 0) } 219 220 return 0 221}