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}