JSON web tokens in OCaml
at main 436 lines 14 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** CBOR Web Token (CWT) - RFC 8392 7 8 This module implements CBOR Web Tokens as specified in 9 {{:https://datatracker.ietf.org/doc/html/rfc8392}RFC 8392}. 10 11 CWTs are the CBOR-based equivalent of JWTs, designed for constrained 12 environments where compact binary representation is important. CWTs use COSE 13 ({{:https://datatracker.ietf.org/doc/html/rfc9052}RFC 9052}) for 14 cryptographic protection. 15 16 {2 Quick Start} 17 18 {[ 19 (* Create claims *) 20 let claims = 21 Cwt.Claims.( 22 empty 23 |> set_iss "https://example.com" 24 |> set_sub "user123" 25 |> set_exp 26 (Ptime.add_span (Ptime_clock.now ()) (Ptime.Span.of_int_s 3600) 27 |> Option.get) 28 |> build) 29 30 (* Create a symmetric key *) 31 let key = 32 Cwt.Cose_key.symmetric 33 (Bytes.of_string "my-secret-key-32-bytes-long!!!!!") 34 35 (* Create and encode the CWT *) 36 let cwt = 37 Cwt.create ~algorithm:Cwt.Algorithm.HMAC_256 ~claims ~key 38 |> Result.get_ok 39 40 let encoded = Cwt.encode cwt 41 42 (* Parse and verify *) 43 let parsed = Cwt.parse encoded |> Result.get_ok 44 let () = Cwt.verify ~key parsed |> Result.get_ok 45 ]} 46 47 {2 References} 48 - {{:https://datatracker.ietf.org/doc/html/rfc8392}RFC 8392} - CBOR Web 49 Token (CWT) 50 - {{:https://datatracker.ietf.org/doc/html/rfc9052}RFC 9052} - CBOR Object 51 Signing and Encryption (COSE) Structures 52 - {{:https://datatracker.ietf.org/doc/html/rfc9053}RFC 9053} - CBOR Object 53 Signing and Encryption (COSE) Algorithms 54 - {{:https://datatracker.ietf.org/doc/html/rfc8949}RFC 8949} - Concise 55 Binary Object Representation (CBOR) *) 56 57(** {1 Error Handling} *) 58 59type error = 60 | Invalid_cbor of string (** CBOR parsing failed *) 61 | Invalid_cose of string (** COSE structure validation failed *) 62 | Invalid_claims of string (** Claims validation failed *) 63 | Unsupported_algorithm of string (** Unknown COSE algorithm identifier *) 64 | Algorithm_not_allowed of string 65 (** Algorithm rejected by allowed_algs policy *) 66 | Signature_mismatch (** Signature/MAC verification failed *) 67 | Token_expired 68 (** exp claim validation failed per 69 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.4}RFC 70 8392 Section 3.1.4} *) 71 | Token_not_yet_valid 72 (** nbf claim validation failed per 73 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.5}RFC 74 8392 Section 3.1.5} *) 75 | Invalid_issuer 76 (** iss claim mismatch per 77 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.1}RFC 78 8392 Section 3.1.1} *) 79 | Invalid_audience 80 (** aud claim mismatch per 81 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.3}RFC 82 8392 Section 3.1.3} *) 83 | Key_type_mismatch of string (** Key doesn't match algorithm *) 84 85val pp_error : Format.formatter -> error -> unit 86(** Pretty-print an error. *) 87 88val error_to_string : error -> string 89(** Convert error to human-readable string. *) 90 91(** {1 COSE Algorithms} 92 93 Cryptographic algorithms for COSE as specified in 94 {{:https://datatracker.ietf.org/doc/html/rfc9053}RFC 9053}. 95 96 Each algorithm has a registered integer identifier in the IANA COSE 97 Algorithms registry. *) 98 99module Algorithm : sig 100 type t = 101 | ES256 (** ECDSA w/ SHA-256, COSE alg = -7 *) 102 | ES384 (** ECDSA w/ SHA-384, COSE alg = -35 *) 103 | ES512 (** ECDSA w/ SHA-512, COSE alg = -36 *) 104 | EdDSA (** EdDSA (Ed25519), COSE alg = -8 *) 105 | HMAC_256_64 (** HMAC w/ SHA-256 truncated to 64 bits, COSE alg = 4 *) 106 | HMAC_256 (** HMAC w/ SHA-256 (256 bits), COSE alg = 5 *) 107 | HMAC_384 (** HMAC w/ SHA-384, COSE alg = 6 *) 108 | HMAC_512 (** HMAC w/ SHA-512, COSE alg = 7 *) 109 110 val to_cose_int : t -> int 111 (** Convert to COSE algorithm identifier (negative for signatures). *) 112 113 val of_cose_int : int -> (t, error) result 114 (** Parse from COSE algorithm identifier. *) 115 116 val to_string : t -> string 117 (** Human-readable name for the algorithm. *) 118 119 val all : t list 120 (** All supported algorithms. *) 121end 122 123(** {1 COSE Key} 124 125 Key representation for COSE operations. See 126 {{:https://datatracker.ietf.org/doc/html/rfc9052#section-7}RFC 9052 Section 127 7} and {{:https://datatracker.ietf.org/doc/html/rfc9053}RFC 9053}. *) 128 129module Cose_key : sig 130 (** Key type per COSE Key Type registry. See 131 {{:https://www.iana.org/assignments/cose/cose.xhtml#key-type}IANA COSE Key 132 Types}. *) 133 type kty = 134 | Okp (** Octet Key Pair (kty = 1), used for EdDSA *) 135 | Ec2 (** Elliptic Curve with x,y coordinates (kty = 2) *) 136 | Symmetric (** Symmetric key (kty = 4) *) 137 138 type t 139 (** A COSE key. 140 141 Supported key types and curves: 142 - Symmetric keys for HMAC algorithms 143 - P-256 (NIST, crv = 1) for ES256 144 - P-384 (NIST, crv = 2) for ES384 145 - P-521 (NIST, crv = 3) for ES512 146 - Ed25519 (crv = 6) for EdDSA *) 147 148 (** {2 Constructors} *) 149 150 val symmetric : string -> t 151 (** [symmetric k] creates a symmetric COSE key from raw bytes. Used for HMAC 152 algorithms. The key should be at least as long as the hash output (32 153 bytes for HMAC_256, etc.). *) 154 155 val ed25519_pub : string -> t 156 (** [ed25519_pub pub] creates an Ed25519 public key from the 32-byte public 157 key value. *) 158 159 val ed25519_priv : pub:string -> priv:string -> t 160 (** [ed25519_priv ~pub ~priv] creates an Ed25519 private key. [pub] is the 161 32-byte public key, [priv] is the 32-byte seed. *) 162 163 val p256_pub : x:string -> y:string -> t 164 (** [p256_pub ~x ~y] creates a P-256 public key from the x and y coordinates 165 (each 32 bytes). *) 166 167 val p256_priv : x:string -> y:string -> d:string -> t 168 (** [p256_priv ~x ~y ~d] creates a P-256 private key. [d] is the 32-byte 169 private key value. *) 170 171 val p384_pub : x:string -> y:string -> t 172 (** [p384_pub ~x ~y] creates a P-384 public key (coordinates are 48 bytes 173 each). *) 174 175 val p384_priv : x:string -> y:string -> d:string -> t 176 (** [p384_priv ~x ~y ~d] creates a P-384 private key. *) 177 178 val p521_pub : x:string -> y:string -> t 179 (** [p521_pub ~x ~y] creates a P-521 public key (coordinates are 66 bytes 180 each). *) 181 182 val p521_priv : x:string -> y:string -> d:string -> t 183 (** [p521_priv ~x ~y ~d] creates a P-521 private key. *) 184 185 (** {2 Accessors} *) 186 187 val kty : t -> kty 188 (** Get the key type. *) 189 190 val kid : t -> string option 191 (** Get the key ID if set (COSE label 2). *) 192 193 val alg : t -> Algorithm.t option 194 (** Get the intended algorithm if set (COSE label 3). *) 195 196 val with_kid : string -> t -> t 197 (** [with_kid id key] returns key with kid set to [id]. *) 198 199 val with_alg : Algorithm.t -> t -> t 200 (** [with_alg alg key] returns key with alg set to [alg]. *) 201 202 (** {2 Serialization} *) 203 204 val of_cbor : string -> (t, error) result 205 (** Parse a COSE key from CBOR bytes. *) 206 207 val to_cbor : t -> string 208 (** Serialize a COSE key to CBOR bytes. *) 209end 210 211(** {1 CWT Claims} 212 213 CWT Claims Set using CBOR integer keys for compactness. See 214 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3}RFC 8392 Section 215 3}. 216 217 {2 Claim Key Mapping} 218 219 | Claim | Integer Key | Type | |-------|-------------|------| | iss | 1 | 220 text string | | sub | 2 | text string | | aud | 3 | text string | | exp | 4 221 | integer (NumericDate) | | nbf | 5 | integer (NumericDate) | | iat | 6 | 222 integer (NumericDate) | | cti | 7 | byte string | *) 223 224module Claims : sig 225 type t 226 227 (** {2 Registered Claim Names} 228 229 See 230 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1}RFC 8392 231 Section 3.1}. *) 232 233 val iss : t -> string option 234 (** Issuer claim (key 1) per 235 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.1}Section 236 3.1.1}. *) 237 238 val sub : t -> string option 239 (** Subject claim (key 2) per 240 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.2}Section 241 3.1.2}. *) 242 243 val aud : t -> string list 244 (** Audience claim (key 3) per 245 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.3}Section 246 3.1.3}. Returns empty list if not present. May be single string or array 247 in CWT. *) 248 249 val exp : t -> Ptime.t option 250 (** Expiration time claim (key 4) per 251 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.4}Section 252 3.1.4}. *) 253 254 val nbf : t -> Ptime.t option 255 (** Not Before claim (key 5) per 256 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.5}Section 257 3.1.5}. *) 258 259 val iat : t -> Ptime.t option 260 (** Issued At claim (key 6) per 261 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.6}Section 262 3.1.6}. *) 263 264 val cti : t -> string option 265 (** CWT ID claim (key 7) per 266 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-3.1.7}Section 267 3.1.7}. Note: Unlike JWT's jti which is a string, CWT's cti is a byte 268 string. *) 269 270 (** {2 Custom Claims} 271 272 CWT supports both integer and text string keys for custom claims. *) 273 274 val get_int_key : int -> t -> Cbort.Cbor.t option 275 (** [get_int_key key claims] returns the CBOR value of custom claim with 276 integer key [key]. *) 277 278 val get_string_key : string -> t -> Cbort.Cbor.t option 279 (** [get_string_key key claims] returns the CBOR value of custom claim with 280 string key [key]. *) 281 282 (** {2 Construction} *) 283 284 type builder 285 (** Builder for constructing claims. *) 286 287 val empty : builder 288 (** Empty claims builder. *) 289 290 val set_iss : string -> builder -> builder 291 (** Set issuer claim. *) 292 293 val set_sub : string -> builder -> builder 294 (** Set subject claim. *) 295 296 val set_aud : string list -> builder -> builder 297 (** Set audience claim. *) 298 299 val set_exp : Ptime.t -> builder -> builder 300 (** Set expiration time claim. *) 301 302 val set_nbf : Ptime.t -> builder -> builder 303 (** Set not-before claim. *) 304 305 val set_iat : Ptime.t -> builder -> builder 306 (** Set issued-at claim. *) 307 308 val set_cti : string -> builder -> builder 309 (** Set CWT ID claim (raw bytes). *) 310 311 val set_int_key : int -> Cbort.Cbor.t -> builder -> builder 312 (** [set_int_key key value builder] sets a custom claim with integer key. 313 [value] is a CBOR value that will be serialized. *) 314 315 val set_string_key : string -> Cbort.Cbor.t -> builder -> builder 316 (** [set_string_key key value builder] sets a custom claim with string key. 317 [value] is a CBOR value that will be serialized. *) 318 319 val build : builder -> t 320 (** Build the claims set. *) 321 322 (** {2 Serialization} *) 323 324 val of_cbor : string -> (t, error) result 325 (** Parse claims from CBOR-encoded CWT Claims Set. *) 326 327 val to_cbor : t -> string 328 (** Serialize claims to CBOR-encoded CWT Claims Set. *) 329end 330 331(** {1 CWT Token} *) 332 333type t 334(** A parsed CWT token (COSE_Sign1 or COSE_Mac0 structure). Note: COSE_Encrypt0 335 is not currently supported. *) 336 337(** {2 Parsing} 338 339 Parse CWT from CBOR bytes. The CWT may be tagged (with COSE tag) or untagged 340 per 341 {{:https://datatracker.ietf.org/doc/html/rfc8392#section-2}RFC 8392 Section 342 2}. *) 343 344val parse : string -> (t, error) result 345(** [parse cwt_bytes] parses a CWT from CBOR bytes. 346 347 This parses the COSE structure and extracts the claims, but does NOT verify 348 the signature/MAC. Use {!verify} to validate cryptographic protection after 349 parsing. *) 350 351(** {2 Accessors} *) 352 353val claims : t -> Claims.t 354(** [claims t] returns the claims set. *) 355 356val algorithm : t -> Algorithm.t option 357(** [algorithm t] returns the COSE algorithm from the protected header. *) 358 359val kid : t -> string option 360(** [kid t] returns the key ID from headers if present. *) 361 362val raw : t -> string 363(** [raw t] returns the original CBOR bytes. *) 364 365(** {2 Verification} 366 367 Verify cryptographic protection and validate claims. *) 368 369val verify : 370 key:Cose_key.t -> ?allowed_algs:Algorithm.t list -> t -> (unit, error) result 371(** [verify ~key ?allowed_algs t] verifies the COSE signature or MAC. 372 373 @param key The key to verify with (must match algorithm) 374 @param allowed_algs List of acceptable algorithms. Default: all. *) 375 376val validate : 377 now:Ptime.t -> 378 ?iss:string -> 379 ?aud:string -> 380 ?leeway:Ptime.Span.t -> 381 t -> 382 (unit, error) result 383(** [validate ~now ?iss ?aud ?leeway t] validates CWT claims. 384 385 @param now Current time (required, no implicit clock) 386 @param iss Expected issuer (if provided, must match exactly) 387 @param aud Expected audience (if provided, must be in audience list) 388 @param leeway Clock skew tolerance for exp/nbf checks (default 0s) *) 389 390val verify_and_validate : 391 key:Cose_key.t -> 392 now:Ptime.t -> 393 ?allowed_algs:Algorithm.t list -> 394 ?iss:string -> 395 ?aud:string -> 396 ?leeway:Ptime.Span.t -> 397 t -> 398 (unit, error) result 399(** [verify_and_validate ~key ~now ...] verifies signature and validates claims. 400*) 401 402(** {2 Creation} 403 404 Create and sign CWTs. *) 405 406val create : 407 algorithm:Algorithm.t -> 408 claims:Claims.t -> 409 key:Cose_key.t -> 410 (t, error) result 411(** [create ~algorithm ~claims ~key] creates and signs a new CWT. 412 413 Creates a COSE_Sign1 structure for signature algorithms (ES256, ES384, 414 ES512, EdDSA) or COSE_Mac0 for MAC algorithms (HMAC_256, HMAC_384, 415 HMAC_512). 416 417 The [key] must be appropriate for the algorithm: 418 - HMAC algorithms: symmetric key 419 - ES256: P-256 private key 420 - ES384: P-384 private key 421 - ES512: P-521 private key 422 - EdDSA: Ed25519 private key *) 423 424val encode : t -> string 425(** [encode t] returns the CBOR serialization of the CWT. The result is a tagged 426 COSE structure (COSE_Sign1 or COSE_Mac0). *) 427 428(** {1 Utilities} *) 429 430val is_expired : now:Ptime.t -> ?leeway:Ptime.Span.t -> t -> bool 431(** [is_expired ~now ?leeway t] checks if the token has expired. Returns false 432 if no exp claim present. *) 433 434val time_to_expiry : now:Ptime.t -> t -> Ptime.Span.t option 435(** [time_to_expiry ~now t] returns time until expiration, or [None] if no 436 expiration claim or already expired. *)