nx_jwt.nx source
↩ module page · 235 lines · 7959 B
1// jwt.nx -- JSON Web Token sign + verify (HS256 only).
2//
3// RFC 7519 JWT + RFC 7515 JWS + RFC 7518 algorithms. Compact
4// serialisation:
5//
6// BASE64URL(header) . BASE64URL(payload) . BASE64URL(sig)
7//
8// Header: {\"alg\":\"HS256\",\"typ\":\"JWT\"}
9// Payload: arbitrary JSON -- caller decides claims
10// Sig: HMAC-SHA-256(key, \"header64.payload64\")
11//
12// HS256 only today -- RS256/ES256 require RSA/ECDSA, which we
13// haven't finished. HS256 is fine for single-service auth
14// (web app signs its own tokens, verifies its own tokens). For
15// third-party delegation (OAuth 2) the stack will need RS256
16// later via rsa.nx (pending).
17//
18// Invariants:
19// J1 Verification uses ct_memcmp for the signature compare --
20// timing-safe against forgery attempts.
21// J2 Input parse is tolerant of missing trailing padding
22// (base64url variant omits '=').
23// J3 We do NOT validate payload claims (exp / nbf / iss).
24// Caller parses the returned payload JSON and checks.
25
26// nx_safety_envelope:
27// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
28// sil_target: SIL1
29// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
30// verdict: NOT_YET_EVALUATED
31
32import "nx_syscalls.nx"
33import "nx_hmac.nx"
34import "nx_base64.nx"
35import "nx_ct.nx"
36
37const JWT_ERR_FORMAT: i64 = -1
38const JWT_ERR_SHORT: i64 = -2
39const JWT_ERR_SIG: i64 = -3
40
41// Encode raw bytes as base64url (RFC 4648 ยง5): +/ -> -_ and no '='.
42// Writes to out; returns bytes written.
43func jwt_b64url_encode(data: *u8, n: i64, out: *u8) -> i64 {
44 // Standard base64 -> then patch + to -, / to _, and strip '='.
45 let b64_len: i64 = b64_encode(data, n, out)
46 var stripped: i64 = b64_len
47 // Strip trailing '='.
48 while stripped > 0 {
49 if out[stripped - 1] != 0x3D { break }
50 stripped = stripped - 1
51 }
52 var i: i64 = 0
53 while i < stripped {
54 if out[i] == 0x2B { out[i] = 0x2D } // '+' -> '-'
55 if out[i] == 0x2F { out[i] = 0x5F } // '/' -> '_'
56 i = i + 1
57 }
58 return stripped
59}
60
61// Decode base64url. Uncensored wrapper: copy input, pad with '=',
62// swap -/_ back to +/, then call b64_decode. Not zero-alloc
63// (we need a scratch buffer for the massaged input).
64func jwt_b64url_decode(chars: *u8, n: i64, out: *u8) -> i64 {
65 // Copy with alphabet swap.
66 let scratch_len: i64 = n + 4
67 let scratch: *u8 = sys_mmap(scratch_len + 16)
68 var i: i64 = 0
69 while i < n {
70 var c: i64 = chars[i]
71 if c == 0x2D { c = 0x2B } // '-' -> '+'
72 if c == 0x5F { c = 0x2F } // '_' -> '/'
73 scratch[i] = c
74 i = i + 1
75 }
76 // Pad to multiple of 4 with '='.
77 var pad_n: i64 = n
78 while pad_n % 4 != 0 {
79 scratch[pad_n] = 0x3D
80 pad_n = pad_n + 1
81 }
82 return b64_decode(scratch, pad_n, out)
83}
84
85// Produce a JWT signed with HS256. `header_json` + `payload_json`
86// are the raw JSON texts -- caller builds them (json_emit.nx is
87// available). Returns bytes written or JWT_ERR_SHORT.
88func jwt_sign_hs256(out: *u8, cap: i64,
89 header_json: *u8, header_len: i64,
90 payload_json: *u8, payload_len: i64,
91 key: *u8, key_len: i64) -> i64 {
92 // Encode header + payload as base64url.
93 // Each base64 chunk is at most ceil(n/3)*4 bytes.
94 let h_b64_cap: i64 = header_len * 2 + 8
95 let p_b64_cap: i64 = payload_len * 2 + 8
96 let h_b64: *u8 = sys_mmap(h_b64_cap)
97 let p_b64: *u8 = sys_mmap(p_b64_cap)
98 let h_b64_len: i64 = jwt_b64url_encode(header_json, header_len, h_b64)
99 let p_b64_len: i64 = jwt_b64url_encode(payload_json, payload_len, p_b64)
100
101 // Build signing input = h_b64 + \".\" + p_b64 into a scratch.
102 let sign_input_len: i64 = h_b64_len + 1 + p_b64_len
103 let sign_input: *u8 = sys_mmap(sign_input_len + 16)
104 var i: i64 = 0
105 while i < h_b64_len {
106 sign_input[i] = h_b64[i]
107 i = i + 1
108 }
109 sign_input[h_b64_len] = 0x2E
110 i = 0
111 while i < p_b64_len {
112 sign_input[h_b64_len + 1 + i] = p_b64[i]
113 i = i + 1
114 }
115
116 // HMAC-SHA-256 -> 32-byte tag.
117 let mac: *u8 = sys_mmap(64)
118 hmac_sha256(key, key_len, sign_input, sign_input_len, mac)
119
120 // Base64url-encode the tag.
121 let sig_b64: *u8 = sys_mmap(128)
122 let sig_b64_len: i64 = jwt_b64url_encode(mac, 32, sig_b64)
123
124 // Concatenate "h.p.s" into out.
125 let total: i64 = sign_input_len + 1 + sig_b64_len
126 if cap < total { return JWT_ERR_SHORT }
127 i = 0
128 while i < sign_input_len {
129 out[i] = sign_input[i]
130 i = i + 1
131 }
132 out[sign_input_len] = 0x2E
133 i = 0
134 while i < sig_b64_len {
135 out[sign_input_len + 1 + i] = sig_b64[i]
136 i = i + 1
137 }
138 return total
139}
140
141// Verify an HS256 JWT. Returns 0 on OK, negative on failure.
142// Writes the payload JSON bytes to payload_out + payload_len_out
143// so the caller can parse claims.
144func jwt_verify_hs256(token: *u8, n: i64,
145 key: *u8, key_len: i64,
146 payload_out: *u8, payload_cap: i64,
147 payload_len_out: *i64) -> i64 {
148 // Find the two dots.
149 var dot1: i64 = -1
150 var dot2: i64 = -1
151 var i: i64 = 0
152 while i < n {
153 if token[i] == 0x2E {
154 if dot1 < 0 {
155 dot1 = i
156 } else {
157 dot2 = i
158 break
159 }
160 }
161 i = i + 1
162 }
163 if dot1 < 0 { return JWT_ERR_FORMAT }
164 if dot2 < 0 { return JWT_ERR_FORMAT }
165
166 let sign_input_len: i64 = dot2
167
168 // Recompute HMAC over header64.payload64.
169 let mac: *u8 = sys_mmap(64)
170 hmac_sha256(key, key_len, token, sign_input_len, mac)
171 let sig_b64: *u8 = sys_mmap(128)
172 let sig_b64_len: i64 = jwt_b64url_encode(mac, 32, sig_b64)
173
174 // Provided signature on the wire.
175 let provided_off: i64 = dot2 + 1
176 let provided_len: i64 = n - provided_off
177 if provided_len != sig_b64_len { return JWT_ERR_SIG }
178 // Constant-time compare.
179 if ct_memcmp(token + provided_off, sig_b64, sig_b64_len) != 0 {
180 return JWT_ERR_SIG
181 }
182
183 // Decode payload base64url into out.
184 let payload_b64_off: i64 = dot1 + 1
185 let payload_b64_len: i64 = dot2 - payload_b64_off
186 // Upper bound on decoded bytes = payload_b64_len * 3/4.
187 if payload_cap < payload_b64_len { return JWT_ERR_SHORT }
188 let decoded_len: i64 = jwt_b64url_decode(
189 token + payload_b64_off, payload_b64_len, payload_out)
190 *payload_len_out = decoded_len
191 return 0
192}
193
194// Compile-only smoke: sign + verify round trip.
195func main() -> i64 {
196 let key: *u8 = "nishi-secret-key"
197 let header: *u8 = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"
198 let payload: *u8 = "{\"sub\":\"elder\",\"exp\":9999999999}"
199
200 let token: *u8 = sys_mmap(512)
201 let token_len: i64 = jwt_sign_hs256(token, 512,
202 header, 27,
203 payload, 32,
204 key, 16)
205 if token_len <= 0 { return 1 }
206
207 // Should contain two dots.
208 var dots: i64 = 0
209 var i: i64 = 0
210 while i < token_len {
211 if token[i] == 0x2E { dots = dots + 1 }
212 i = i + 1
213 }
214 if dots != 2 { return 2 }
215
216 // Verify.
217 let payload_out: *u8 = sys_mmap(256)
218 let p_len_out: *i64 = (sys_mmap(16)) as *i64
219 let rc: i64 = jwt_verify_hs256(token, token_len,
220 key, 16,
221 payload_out, 256, p_len_out)
222 if rc != 0 { return 3 }
223 if *p_len_out != 32 { return 4 }
224 // First byte of decoded payload is '{'
225 if payload_out[0] != 0x7B { return 5 }
226
227 // Tamper last byte -> signature fails.
228 token[token_len - 1] = token[token_len - 1] ^ 1
229 if jwt_verify_hs256(token, token_len,
230 key, 16,
231 payload_out, 256, p_len_out) != JWT_ERR_SIG {
232 return 6
233 }
234 return 0
235}