totp_uri.nx source
↩ module page · 176 lines · 6055 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
29import "syscalls.nx"
30import "base32.nx"
31import "url.nx"
32
33const TU_ERR_SHORT: i64 = -1
34
35// Append literal bytes. Returns new offset.
36func tu_put(out: *u8, cap: i64, off: i64, src: *u8, n: i64) -> i64 {
37 if off + n > cap { return TU_ERR_SHORT }
38 var i: i64 = 0
39 while i < n {
40 out[off + i] = src[i]
41 i = i + 1
42 }
43 return off + n
44}
45
46// Percent-encode and append. Uses url_encode_component (full
47// reserved-char escape).
48func tu_put_enc(out: *u8, cap: i64, off: i64,
49 src: *u8, n: i64) -> i64 {
50 // Bound: worst-case each byte encodes to %HH (3x). Bail if
51 // insufficient room; conservative check so we can pass the
52 // caller buffer tail safely.
53 if off + n * 3 > cap { return TU_ERR_SHORT }
54 let w: i64 = url_encode_component(src, n, out + off)
55 if w < 0 { return TU_ERR_SHORT }
56 return off + w
57}
58
59// Emit a decimal integer. Used for digits + period.
60func tu_put_dec(out: *u8, cap: i64, off: i64, v: i64) -> i64 {
61 if v == 0 {
62 if off + 1 > cap { return TU_ERR_SHORT }
63 out[off] = 0x30
64 return off + 1
65 }
66 let digits_raw: *u8 = sys_mmap(32)
67 var tmp: i64 = v
68 var n: i64 = 0
69 while tmp > 0 {
70 digits_raw[n] = 0x30 + (tmp % 10)
71 tmp = tmp / 10
72 n = n + 1
73 }
74 if off + n > cap { return TU_ERR_SHORT }
75 var i: i64 = n - 1
76 var cur: i64 = off
77 while i >= 0 {
78 out[cur] = digits_raw[i]
79 cur = cur + 1
80 i = i - 1
81 }
82 return cur
83}
84
85// Build an otpauth://totp URI.
86//
87// label_bytes: typically "Issuer:user@example.com"
88// secret_bytes: raw secret (NOT base32; encoded internally)
89// issuer_bytes: optional brand name; pass 0-length to skip
90// digits: typically 6
91// period: typically 30 seconds
92//
93// Caller supplies ASCII/UTF-8 label + issuer as raw bytes; we
94// percent-encode them. algorithm is hardcoded SHA1 (the default
95// Google Authenticator uses; SHA256/SHA512 variants are available
96// via separate builders).
97func totp_uri_build(out: *u8, cap: i64,
98 label: *u8, label_len: i64,
99 secret: *u8, secret_len: i64,
100 issuer: *u8, issuer_len: i64,
101 digits: i64, period: i64) -> i64 {
102 var cur: i64 = 0
103 cur = tu_put(out, cap, cur, "otpauth://totp/", 15)
104 if cur < 0 { return cur }
105 cur = tu_put_enc(out, cap, cur, label, label_len)
106 if cur < 0 { return cur }
107 cur = tu_put(out, cap, cur, "?secret=", 8)
108 if cur < 0 { return cur }
109
110 // Base32-encode the secret directly into out[cur..]. The
111 // base32 encoder writes into a caller-provided buffer. We
112 // need to compute the length first so we can check cap.
113 // base32 is 8 chars per 5 bytes; ceil(secret_len/5)*8 with
114 // padding; we skip padding below.
115 let sec_scratch: *u8 = sys_mmap(secret_len * 2 + 16)
116 let sec_len: i64 = base32_encode(secret, secret_len, sec_scratch)
117 // Strip '=' padding.
118 var stripped: i64 = sec_len
119 while stripped > 0 {
120 if sec_scratch[stripped - 1] != 0x3D { break }
121 stripped = stripped - 1
122 }
123 cur = tu_put(out, cap, cur, sec_scratch, stripped)
124 if cur < 0 { return cur }
125
126 if issuer_len > 0 {
127 cur = tu_put(out, cap, cur, "&issuer=", 8)
128 if cur < 0 { return cur }
129 cur = tu_put_enc(out, cap, cur, issuer, issuer_len)
130 if cur < 0 { return cur }
131 }
132
133 cur = tu_put(out, cap, cur, "&algorithm=SHA1&digits=", 23)
134 if cur < 0 { return cur }
135 cur = tu_put_dec(out, cap, cur, digits)
136 if cur < 0 { return cur }
137 cur = tu_put(out, cap, cur, "&period=", 8)
138 if cur < 0 { return cur }
139 cur = tu_put_dec(out, cap, cur, period)
140 if cur < 0 { return cur }
141
142 return cur
143}
144
145// Compile-only smoke.
146func main() -> i64 {
147 let out: *u8 = sys_mmap(256)
148 let secret: *u8 = "12345678901234567890" // 20 bytes
149 let n: i64 = totp_uri_build(out, 256,
150 "Nishi:elder", 11,
151 secret, 20,
152 "Nishi", 5,
153 6, 30)
154 if n <= 0 { return 1 }
155 // URI must start with "otpauth://totp/".
156 if out[0] != 0x6F { return 2 } // 'o'
157 if out[7] != 0x2F { return 3 } // '/'
158 if out[14] != 0x2F { return 4 } // '/'
159
160 // Secret query param must appear.
161 var i: i64 = 0
162 var found_secret: i64 = 0
163 while i < n - 7 {
164 if out[i] == 0x3F { // '?'
165 if out[i+1] == 0x73 { // 's'
166 if out[i+2] == 0x65 { // 'e'
167 found_secret = 1
168 break
169 }
170 }
171 }
172 i = i + 1
173 }
174 if found_secret != 1 { return 5 }
175 return 0
176}