code wiki / (root) / nx_jwt.nx

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}