nx_totp_uri.nx source
↩ module page · 182 lines · 6149 B
1// totp_uri.nx -- build otpauth:// enrollment URIs.
2//
3// Google Authenticator Key URI Format (de-facto standard, used by
4// Authy / 1Password / Microsoft Authenticator / Duo). A QR code
5// encodes this URI; scanning provisions a new OTP account.
6//
7// Shape:
8// otpauth://totp/Label?secret=BASE32SECRET&issuer=Name&algorithm=SHA1
9// &digits=6&period=30
10//
11// Label is typically \"Issuer:user@example.com\" (colon-separated).
12// Secret is a base32-encoded arbitrary-length key -- 20 bytes is
13// the RFC 6238 recommended length for SHA-1.
14//
15// Composes base32.nx (for secret encoding) + url.nx (for
16// percent-encoding the label + issuer).
17//
18// Invariants:
19// TU1 Secret bytes are base32-encoded with '=' padding stripped
20// (most authenticator apps tolerate padding but some
21// reject it).
22// TU2 Label / issuer percent-encoded so `@`, spaces, `:` are
23// safe in the URI.
24// TU3 Default params (digits=6, period=30, algo=SHA1) match
25// what Google Authenticator assumes on missing params.
26// We emit them anyway for clarity + compat with strict
27// parsers.
28
29// nx_safety_envelope:
30// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
31// sil_target: SIL1
32// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
33// verdict: NOT_YET_EVALUATED
34
35import "nx_syscalls.nx"
36import "nx_base32.nx"
37import "nx_url_canonical.nx"
38
39const TU_ERR_SHORT: i64 = -1
40
41// Append literal bytes. Returns new offset.
42func tu_put(out: *u8, cap: i64, off: i64, src: *u8, n: i64) -> i64 {
43 if off + n > cap { return TU_ERR_SHORT }
44 var i: i64 = 0
45 while i < n {
46 out[off + i] = src[i]
47 i = i + 1
48 }
49 return off + n
50}
51
52// Percent-encode and append. Uses url_encode_component (full
53// reserved-char escape).
54func tu_put_enc(out: *u8, cap: i64, off: i64,
55 src: *u8, n: i64) -> i64 {
56 // Bound: worst-case each byte encodes to %HH (3x). Bail if
57 // insufficient room; conservative check so we can pass the
58 // caller buffer tail safely.
59 if off + n * 3 > cap { return TU_ERR_SHORT }
60 let w: i64 = url_encode_component(src, n, out + off)
61 if w < 0 { return TU_ERR_SHORT }
62 return off + w
63}
64
65// Emit a decimal integer. Used for digits + period.
66func tu_put_dec(out: *u8, cap: i64, off: i64, v: i64) -> i64 {
67 if v == 0 {
68 if off + 1 > cap { return TU_ERR_SHORT }
69 out[off] = 0x30
70 return off + 1
71 }
72 let digits_raw: *u8 = sys_mmap(32)
73 var tmp: i64 = v
74 var n: i64 = 0
75 while tmp > 0 {
76 digits_raw[n] = 0x30 + (tmp % 10)
77 tmp = tmp / 10
78 n = n + 1
79 }
80 if off + n > cap { return TU_ERR_SHORT }
81 var i: i64 = n - 1
82 var cur: i64 = off
83 while i >= 0 {
84 out[cur] = digits_raw[i]
85 cur = cur + 1
86 i = i - 1
87 }
88 return cur
89}
90
91// Build an otpauth://totp URI.
92//
93// label_bytes: typically "Issuer:user@example.com"
94// secret_bytes: raw secret (NOT base32; encoded internally)
95// issuer_bytes: optional brand name; pass 0-length to skip
96// digits: typically 6
97// period: typically 30 seconds
98//
99// Caller supplies ASCII/UTF-8 label + issuer as raw bytes; we
100// percent-encode them. algorithm is hardcoded SHA1 (the default
101// Google Authenticator uses; SHA256/SHA512 variants are available
102// via separate builders).
103func totp_uri_build(out: *u8, cap: i64,
104 label: *u8, label_len: i64,
105 secret: *u8, secret_len: i64,
106 issuer: *u8, issuer_len: i64,
107 digits: i64, period: i64) -> i64 {
108 var cur: i64 = 0
109 cur = tu_put(out, cap, cur, "otpauth://totp/", 15)
110 if cur < 0 { return cur }
111 cur = tu_put_enc(out, cap, cur, label, label_len)
112 if cur < 0 { return cur }
113 cur = tu_put(out, cap, cur, "?secret=", 8)
114 if cur < 0 { return cur }
115
116 // Base32-encode the secret directly into out[cur..]. The
117 // base32 encoder writes into a caller-provided buffer. We
118 // need to compute the length first so we can check cap.
119 // base32 is 8 chars per 5 bytes; ceil(secret_len/5)*8 with
120 // padding; we skip padding below.
121 let sec_scratch: *u8 = sys_mmap(secret_len * 2 + 16)
122 let sec_len: i64 = base32_encode(secret, secret_len, sec_scratch)
123 // Strip '=' padding.
124 var stripped: i64 = sec_len
125 while stripped > 0 {
126 if sec_scratch[stripped - 1] != 0x3D { break }
127 stripped = stripped - 1
128 }
129 cur = tu_put(out, cap, cur, sec_scratch, stripped)
130 if cur < 0 { return cur }
131
132 if issuer_len > 0 {
133 cur = tu_put(out, cap, cur, "&issuer=", 8)
134 if cur < 0 { return cur }
135 cur = tu_put_enc(out, cap, cur, issuer, issuer_len)
136 if cur < 0 { return cur }
137 }
138
139 cur = tu_put(out, cap, cur, "&algorithm=SHA1&digits=", 23)
140 if cur < 0 { return cur }
141 cur = tu_put_dec(out, cap, cur, digits)
142 if cur < 0 { return cur }
143 cur = tu_put(out, cap, cur, "&period=", 8)
144 if cur < 0 { return cur }
145 cur = tu_put_dec(out, cap, cur, period)
146 if cur < 0 { return cur }
147
148 return cur
149}
150
151// Compile-only smoke.
152func main() -> i64 {
153 let out: *u8 = sys_mmap(256)
154 let secret: *u8 = "12345678901234567890" // 20 bytes
155 let n: i64 = totp_uri_build(out, 256,
156 "Nishi:elder", 11,
157 secret, 20,
158 "Nishi", 5,
159 6, 30)
160 if n <= 0 { return 1 }
161 // URI must start with "otpauth://totp/".
162 if out[0] != 0x6F { return 2 } // 'o'
163 if out[7] != 0x2F { return 3 } // '/'
164 if out[14] != 0x2F { return 4 } // '/'
165
166 // Secret query param must appear.
167 var i: i64 = 0
168 var found_secret: i64 = 0
169 while i < n - 7 {
170 if out[i] == 0x3F { // '?'
171 if out[i+1] == 0x73 { // 's'
172 if out[i+2] == 0x65 { // 'e'
173 found_secret = 1
174 break
175 }
176 }
177 }
178 i = i + 1
179 }
180 if found_secret != 1 { return 5 }
181 return 0
182}