nx_totp_verify.nx source
↩ module page · 107 lines · 4206 B
1// totp_verify.nx -- TOTP code verification with clock-skew window.
2//
3// Companion to totp_sha1.nx. A bare "is the code equal to
4// totp(secret, now)?" check fails whenever the user's device
5// clock drifts by a few seconds past the step boundary. RFC 6238
6// ยง5.2 recommends accepting the code from step N-1 and N+1 as
7// well, for a 3-window tolerance. We generalise to caller-chosen
8// window size.
9//
10// Uses constant-time comparison (ct.nx) across all candidate
11// windows so timing doesn't leak which window matched (hardening
12// against code-enumeration attacks from a malicious client).
13//
14// Invariants:
15// TV1 Provided code compared against every window in
16// [now_step - window, now_step + window], inclusive.
17// TV2 Match check uses ct_eq on the integer value -- no
18// timing side-channel per window.
19// TV3 Returns matched window offset (negative meaning older
20// step, 0 = current, positive = newer) on success, or
21// TV_ERR_NO_MATCH if nothing in range matches.
22
23// nx_safety_envelope:
24// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
25// sil_target: SIL1
26// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
27// verdict: NOT_YET_EVALUATED
28
29import "nx_syscalls.nx"
30import "nx_totp_sha1.nx"
31import "nx_ct.nx"
32const TV_MAGIC_287082: i64 = 287082
33const TV_MAGIC_999999: i64 = 999999
34
35const TV_ERR_NO_MATCH: i64 = -1000 // way outside any reasonable window
36
37// Verify a TOTP code against a secret. Returns matched window
38// offset (usually -1, 0, or 1) on success. Walks all windows
39// unconditionally -- the caller learns only (matched, which_offset)
40// not which individual step hit.
41func totp_sha1_verify(secret: *u8, secret_len: i64,
42 provided_code: i64,
43 now_unix: i64,
44 step: i64,
45 digits: i64,
46 window: i64) -> i64 {
47 let base_counter: i64 = now_unix / step
48 var match_offset: i64 = TV_ERR_NO_MATCH
49 var match_mask: i64 = 0 // OR-accumulator for timing
50 var off: i64 = 0 - window
51 while off <= window {
52 let cand: i64 = hotp_sha1_value(secret, secret_len,
53 base_counter + off, digits)
54 let hit: i64 = ct_eq(cand, provided_code)
55 // Record first match but keep iterating so total work
56 // equals 2*window+1 HMAC invocations regardless.
57 if hit == 1 {
58 if match_mask == 0 {
59 match_offset = off
60 match_mask = 1
61 }
62 }
63 off = off + 1
64 }
65 return match_offset
66}
67
68// Convenience: verify with default (step=30, digits=6, window=1).
69func totp_sha1_verify_default(secret: *u8, secret_len: i64,
70 provided_code: i64,
71 now_unix: i64) -> i64 {
72 return totp_sha1_verify(secret, secret_len, provided_code,
73 now_unix, 30, 6, 1)
74}
75
76// Compile-only smoke using RFC 6238 Appendix B vector:
77// secret = "12345678901234567890" (20 bytes)
78// T=59 -> code 94287082 (8 digits) -- we use 6 here: 287082
79func main() -> i64 {
80 let k: *u8 = "12345678901234567890"
81
82 // 6-digit code at T=59 is 287082.
83 let correct: i64 = totp_sha1_value(k, 20, 59, 30, 6)
84 if correct != TV_MAGIC_287082 { return 1 }
85
86 // Verify at the same time -> match offset 0.
87 let r1: i64 = totp_sha1_verify(k, 20, TV_MAGIC_287082, 59, 30, 6, 1)
88 if r1 != 0 { return 2 }
89
90 // Late: provided code is from 30s in the past (still in window=1).
91 let r2: i64 = totp_sha1_verify(k, 20, TV_MAGIC_287082, 89, 30, 6, 1)
92 if r2 != -1 { return 3 }
93
94 // Early: provided code is from 30s in the future.
95 let r3: i64 = totp_sha1_verify(k, 20, TV_MAGIC_287082, 29, 30, 6, 1)
96 if r3 != 1 { return 4 }
97
98 // Outside window -> no match.
99 let r4: i64 = totp_sha1_verify(k, 20, TV_MAGIC_287082, 200, 30, 6, 1)
100 if r4 != TV_ERR_NO_MATCH { return 5 }
101
102 // Wrong code -> no match even at right time.
103 let r5: i64 = totp_sha1_verify(k, 20, TV_MAGIC_999999, 59, 30, 6, 1)
104 if r5 != TV_ERR_NO_MATCH { return 6 }
105
106 return 0
107}