nx_otp.nx source
↩ module page · 117 lines · 4159 B
1// otp.nx -- HOTP (RFC 4226) + TOTP (RFC 6238) one-time passwords.
2//
3// Powers the 6-digit codes in Google Authenticator / Authy /
4// Yubico Authenticator / Microsoft Authenticator / 1Password.
5//
6// HOTP = HMAC(key, counter) -> 6-digit code (event-counter based)
7// TOTP = HOTP(key, floor(time / period)) -- time-based variant
8//
9// Standard RFC profiles use HMAC-SHA-1 for compatibility. We
10// ship HMAC-SHA-256 variants here because:
11// - SHA-1 is deprecated for new systems (rule: don't encourage)
12// - RFC 6238 ยง5 explicitly permits SHA-256 / SHA-512
13// - All major authenticator apps support SHA-256 profiles
14// Callers interoperating with legacy SHA-1 providers need a
15// separate hotp_sha1.nx we're not shipping.
16//
17// Algorithm (RFC 4226):
18// 1. HMAC = HMAC-SHA-256(key, counter as u64 big-endian)
19// 2. offset = HMAC[31] & 0x0F (dynamic truncation)
20// 3. truncated = read 4 bytes BE at HMAC[offset..offset+4]
21// 4. truncated &= 0x7FFFFFFF (clear top bit)
22// 5. code = truncated mod 10^digits
23//
24// Invariants:
25// OTP1 digits in [6, 10]; most deployments use 6. We don't
26// enforce; caller picks.
27// OTP2 Counter is u64 big-endian; identical on every platform.
28// OTP3 Code is zero-padded to `digits` length on render.
29
30// nx_safety_envelope:
31// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
32// sil_target: SIL1
33// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
34// verdict: NOT_YET_EVALUATED
35
36import "nx_syscalls.nx"
37import "nx_hmac.nx"
38
39// HOTP core: compute the numeric code (pre-rendering).
40func hotp_value(key: *u8, key_len: i64, counter: i64, digits: i64) -> i64 {
41 // Serialise counter as u64 big-endian.
42 let counter_bytes: *u8 = sys_mmap(16)
43 var i: i64 = 0
44 while i < 8 {
45 counter_bytes[i] = (counter >> ((7 - i) * 8)) & 0xFF
46 i = i + 1
47 }
48 // HMAC.
49 let hmac_out: *u8 = sys_mmap(32)
50 hmac_sha256(key, key_len, counter_bytes, 8, hmac_out)
51
52 // Dynamic truncation.
53 let offset: i64 = hmac_out[31] & 0x0F
54 let b0: i64 = hmac_out[offset] & 0x7F
55 let b1: i64 = hmac_out[offset + 1] & 0xFF
56 let b2: i64 = hmac_out[offset + 2] & 0xFF
57 let b3: i64 = hmac_out[offset + 3] & 0xFF
58 let truncated: i64 = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3
59
60 // Modulo 10^digits.
61 var modulus: i64 = 1
62 var d: i64 = 0
63 while d < digits {
64 modulus = modulus * 10
65 d = d + 1
66 }
67 return truncated % modulus
68}
69
70// Render an OTP value as zero-padded ASCII digits. Returns length
71// written = digits.
72func otp_render(value: i64, digits: i64, out: *u8) -> i64 {
73 var v: i64 = value
74 var i: i64 = digits - 1
75 while i >= 0 {
76 out[i] = 0x30 + (v % 10)
77 v = v / 10
78 i = i - 1
79 }
80 return digits
81}
82
83// Complete HOTP: compute + render.
84func hotp_sha256(key: *u8, key_len: i64, counter: i64, digits: i64,
85 out: *u8) -> i64 {
86 let v: i64 = hotp_value(key, key_len, counter, digits)
87 return otp_render(v, digits, out)
88}
89
90// TOTP = HOTP(key, floor(unix_time / period)).
91// Standard period is 30 seconds. `unix_time` is Unix seconds
92// since epoch (caller fetches via time.nx's monotonic/wall helper).
93func totp_sha256(key: *u8, key_len: i64,
94 unix_time: i64, period: i64, digits: i64,
95 out: *u8) -> i64 {
96 let counter: i64 = unix_time / period
97 return hotp_sha256(key, key_len, counter, digits, out)
98}
99
100// Compile-only smoke: HOTP with RFC 4226 test key + counter=0.
101// RFC 4226 uses SHA-1 so we can't match the RFC vector exactly
102// (we use SHA-256). But the algorithm structure is validated by
103// the code compiling + producing a deterministic digit.
104func main() -> i64 {
105 let key: *u8 = "12345678901234567890"
106 let out: *u8 = sys_mmap(16)
107 let len: i64 = hotp_sha256(key, 20, 0, 6, out)
108 if len != 6 { return 1 }
109 // Every char should be 0x30..0x39.
110 var i: i64 = 0
111 while i < 6 {
112 if out[i] < 0x30 { return 2 }
113 if out[i] > 0x39 { return 3 }
114 i = i + 1
115 }
116 return 0
117}