A JWT library for Gleam
0

Configure Feed

Select the types of activity you want to include in your feed.

:sparkles: reject extended/formatted base64url segments

+126 -11
+2
CHANGELOG.md
··· 30 30 critical JOSE header extensions. 31 31 - Reject JWTs with `b64: false` using the new `UnsupportedUnencodedPayload` 32 32 error, as ywt does not support unencoded JWS payloads. 33 + - Reject padded, whitespace-containing, or otherwise non-base64url JWT compact 34 + segments and JWK binary/integer fields before decoding them. 33 35 - Handle duplicate JSON object members consistently for decoded JWT protected 34 36 headers and claim sets by keeping the lexically last value, matching 35 37 JavaScript's `JSON.parse` behavior on all targets.
+12
core/src/ywt/internal/base64url.gleam
··· 1 + import gleam/bit_array 2 + 3 + pub fn decode(input: String) -> Result(BitArray, Nil) { 4 + case is_valid(input) { 5 + True -> bit_array.base64_url_decode(input) 6 + False -> Error(Nil) 7 + } 8 + } 9 + 10 + @external(erlang, "ywt_core_ffi", "is_base64url_unpadded") 11 + @external(javascript, "../../ywt_core_ffi.mjs", "is_base64url_unpadded") 12 + pub fn is_valid(input: String) -> Bool
+2 -1
core/src/ywt/internal/core.gleam
··· 4 4 import gleam/json.{type Json} 5 5 import gleam/order 6 6 import gleam/time/timestamp 7 + import ywt/internal/base64url 7 8 8 9 pub type Error { 9 10 MalformedToken ··· 70 71 71 72 pub fn bits_decoder() { 72 73 use str <- decode.then(decode.string) 73 - case bit_array.base64_url_decode(str) { 74 + case base64url.decode(str) { 74 75 Ok(bits) -> decode.success(bits) 75 76 Error(_) -> decode.failure(<<>>, "Base64Url") 76 77 }
+3 -2
core/src/ywt/internal/jwt.gleam
··· 8 8 import gleam/string 9 9 import ywt/algorithm.{type Algorithm} 10 10 import ywt/claim.{type Claim} 11 + import ywt/internal/base64url 11 12 import ywt/internal/core.{type Error} 12 13 import ywt/internal/jose_json 13 14 import ywt/sign_key.{type SignKey} ··· 124 125 // following the restriction that no line breaks, whitespace, or 125 126 // other additional characters have been used. 126 127 use signature <- result.try( 127 - bit_array.base64_url_decode(raw_signature) 128 + base64url.decode(raw_signature) 128 129 |> result.replace_error(core.InvalidSignatureEncoding), 129 130 ) 130 131 ··· 214 215 // restriction that no line breaks, whitespace, or other additional 215 216 // characters have been used. 216 217 use decoded <- result.try( 217 - bit_array.base64_url_decode(part) 218 + base64url.decode(part) 218 219 |> result.replace_error(encoding_error), 219 220 ) 220 221
+16 -1
core/src/ywt_core_ffi.erl
··· 1 1 -module(ywt_core_ffi). 2 2 3 - -export([decode_json_to_dynamic/1, random_id/0]). 3 + -export([decode_json_to_dynamic/1, is_base64url_unpadded/1, random_id/0]). 4 4 5 5 random_id() -> 6 6 binary:encode_hex( 7 7 crypto:strong_rand_bytes(16)). 8 + 9 + is_base64url_unpadded(Bin) when is_binary(Bin) -> 10 + byte_size(Bin) rem 4 =/= 1 andalso is_base64url_bytes(Bin). 11 + 12 + is_base64url_bytes(<<>>) -> 13 + true; 14 + is_base64url_bytes(<<C, Rest/binary>>) 15 + when C >= $A, C =< $Z; 16 + C >= $a, C =< $z; 17 + C >= $0, C =< $9; 18 + C =:= $-; 19 + C =:= $_ -> 20 + is_base64url_bytes(Rest); 21 + is_base64url_bytes(_) -> 22 + false. 8 23 9 24 decode_json_to_dynamic(Json) -> 10 25 try
+6
core/src/ywt_core_ffi.mjs
··· 1 + const base64UrlUnpaddedPattern = /^[A-Za-z0-9_-]*$/; 2 + 1 3 export function random_id() { 2 4 return crypto.randomUUID(); 3 5 } 6 + 7 + export function is_base64url_unpadded(input) { 8 + return input.length % 4 !== 1 && base64UrlUnpaddedPattern.test(input); 9 + }
+2 -1
erlang/src/ywt.gleam
··· 24 24 import gleam/time/timestamp 25 25 import ywt/algorithm.{type Algorithm} 26 26 import ywt/claim.{type Claim} 27 + import ywt/internal/base64url 27 28 import ywt/internal/core 28 29 import ywt/internal/jwt 29 30 import ywt/sign_key.{type SignKey} ··· 204 205 signature: String, 205 206 key: VerifyKey, 206 207 ) -> Bool { 207 - case bit_array.base64_url_decode(signature) { 208 + case base64url.decode(signature) { 208 209 Ok(signature) -> verify_bits(<<message:utf8>>, signature, key) 209 210 Error(_) -> False 210 211 }
+81 -5
test/tests.gleam
··· 8 8 import lazy_const 9 9 import simplifile 10 10 import ywt.{ 11 - ClaimDecodingError, HeaderDecodingError, InvalidAudience, InvalidHeaderJson, 12 - InvalidIssuer, InvalidSignature, InvalidType, MalformedToken, MissingClaim, 13 - NoMatchingKey, TokenExpired, TokenNotYetValid, UnsupportedCriticalHeader, 14 - UnsupportedUnencodedPayload, decode as parse, 15 - decode_unsafely_without_validation, encode, sign_bits, verify_bits, 11 + ClaimDecodingError, HeaderDecodingError, InvalidAudience, 12 + InvalidHeaderEncoding, InvalidHeaderJson, InvalidIssuer, 13 + InvalidPayloadEncoding, InvalidSignature, InvalidSignatureEncoding, 14 + InvalidType, MalformedToken, MissingClaim, NoMatchingKey, TokenExpired, 15 + TokenNotYetValid, UnsupportedCriticalHeader, UnsupportedUnencodedPayload, 16 + decode as parse, decode_unsafely_without_validation, encode, sign_bits, 17 + sign_string, verify_bits, verify_string, 16 18 } 17 19 import ywt/algorithm 18 20 import ywt/claim.{ ··· 291 293 292 294 use result <- await(parse(".payload.", decode.dynamic, [], [verify_key])) 293 295 let assert Error(InvalidHeaderJson(json.UnexpectedEndOfInput)) = result 296 + 297 + resolve(Nil) 298 + } 299 + 300 + pub fn rejects_non_strict_base64url_segments_test() { 301 + let header = 302 + json.object([ 303 + #("alg", algorithm.to_json(algorithm.hs256)), 304 + ]) 305 + let valid_header = 306 + bit_array.base64_url_encode(<<json.to_string(header):utf8>>, False) 307 + let valid_payload = bit_array.base64_url_encode(<<"{}":utf8>>, False) 308 + let valid_signature = bit_array.base64_url_encode(<<"fake":utf8>>, False) 309 + 310 + use result <- await( 311 + parse( 312 + valid_header <> "+" <> "." <> valid_payload <> "." <> valid_signature, 313 + decode.dynamic, 314 + [], 315 + [], 316 + ), 317 + ) 318 + let assert Error(InvalidHeaderEncoding) = result 319 + 320 + use result <- await( 321 + parse( 322 + valid_header <> "." <> valid_payload <> "=" <> "." <> valid_signature, 323 + decode.dynamic, 324 + [], 325 + [], 326 + ), 327 + ) 328 + let assert Error(InvalidPayloadEncoding) = result 329 + 330 + use result <- await( 331 + parse( 332 + valid_header <> "." <> valid_payload <> "." <> valid_signature <> "\n", 333 + decode.dynamic, 334 + [], 335 + [], 336 + ), 337 + ) 338 + let assert Error(InvalidSignatureEncoding) = result 294 339 295 340 resolve(Nil) 296 341 } ··· 1090 1135 resolve(Nil) 1091 1136 } 1092 1137 1138 + pub fn oct_sign_key_decoder_rejects_non_strict_base64url_key_test() { 1139 + let secret = string.repeat("a", times: 32) 1140 + let key = bit_array.base64_url_encode(<<secret:utf8>>, False) 1141 + let jwk = 1142 + json.object([ 1143 + #("kty", json.string("oct")), 1144 + #("alg", json.string("HS256")), 1145 + #("k", json.string(key <> "=")), 1146 + ]) 1147 + |> json.to_string 1148 + 1149 + let assert Error(_) = json.parse(jwk, sign_key.decoder()) 1150 + 1151 + resolve(Nil) 1152 + } 1153 + 1093 1154 // ================================================================================================ 1094 1155 // 9. EC JWK ALGORITHM FIELD VALIDATION TESTS 1095 1156 // Ensures the EC key decoder correctly handles the optional "alg" field per RFC 7517 §4.4 ··· 1219 1280 use result <- await(verify_bits(message, signature, verify_key)) 1220 1281 1221 1282 assert result == False 1283 + resolve(Nil) 1284 + } 1285 + 1286 + pub fn verify_string_rejects_non_strict_base64url_signature_test() { 1287 + let assert [sign_key, ..] = hmac_keys() 1288 + let verify_key = verify_key.derived(sign_key) 1289 + let message = "header.payload" 1290 + 1291 + use signature <- await(sign_string(message, sign_key)) 1292 + use result <- await(verify_string(message, signature, verify_key)) 1293 + assert result == True 1294 + 1295 + use result <- await(verify_string(message, signature <> "=", verify_key)) 1296 + assert result == False 1297 + 1222 1298 resolve(Nil) 1223 1299 } 1224 1300
+2 -1
webcrypto/src/ywt.gleam
··· 25 25 import gleam/time/timestamp 26 26 import ywt/algorithm.{type Algorithm} 27 27 import ywt/claim.{type Claim} 28 + import ywt/internal/base64url 28 29 import ywt/internal/core 29 30 import ywt/internal/jwt 30 31 import ywt/sign_key.{type SignKey} ··· 207 208 signature: String, 208 209 key: VerifyKey, 209 210 ) -> Promise(Bool) { 210 - case bit_array.base64_url_decode(signature) { 211 + case base64url.decode(signature) { 211 212 Ok(signature) -> verify_bits(<<message:utf8>>, signature, key) 212 213 Error(_) -> resolve(False) 213 214 }