CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(jwt): add hand-rolled compact JWS helpers for ES256/ES256K

Provides minimal RFC 7515 JWT encoder/decoder with:
- encode_compact() to mint signed JWS tokens
- decode_compact() to parse tokens without verification
- verify_compact() to verify signatures against AnyVerifyingKey

Supports both ES256K (secp256k1) and ES256 (P-256) curves.
Includes JwtHeader and JwtClaims structs for atproto service-auth.

7 unit tests verify round-trip encode/decode for both curves, signature
verification failures, malformed input rejection, and curve mismatch detection.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

authored by

Jack Grigg
Claude Haiku 4.5
and committed by
Tangled
73ac7d1e 6e562354

+376
+1
src/common.rs
··· 2 2 3 3 pub mod diagnostics; 4 4 pub mod identity; 5 + pub mod jwt; 5 6 6 7 pub(crate) static APP_USER_AGENT: &str = 7 8 concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
+375
src/common/jwt.rs
··· 1 + //! Minimal hand-rolled JWT (RFC 7515 compact JWS) encoder and decoder for 2 + //! atproto service-auth. 3 + //! 4 + //! This module exists to avoid pulling a full JWT library for a handful of 5 + //! tightly-scoped use cases: minting self-mint JWTs for labeler conformance 6 + //! tests, and decoding them in tests to verify round-trip correctness. 7 + //! Only ES256 and ES256K are supported (RFC 7518 §3.4); raw r||s signature 8 + //! encoding, unpadded base64url segments, UTF-8 JSON payloads. 9 + 10 + use base64::Engine; 11 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 12 + use serde::{Deserialize, Serialize}; 13 + use thiserror::Error; 14 + 15 + use crate::common::identity::{AnySignature, AnySignatureError, AnySigningKey, AnyVerifyingKey}; 16 + 17 + /// Compact JWS header for atproto service-auth tokens. 18 + /// 19 + /// Field names map 1:1 to the JWS wire format (RFC 7515 §4.1). Do NOT 20 + /// rename without updating `serde` attributes — atproto wire format 21 + /// requires exactly `alg` and `typ`. 22 + #[derive(Debug, Clone, Serialize, Deserialize)] 23 + pub struct JwtHeader { 24 + /// Algorithm identifier: "ES256K" (secp256k1) or "ES256" (P-256). 25 + pub alg: String, 26 + /// Token type; always "JWT". 27 + pub typ: String, 28 + } 29 + 30 + impl JwtHeader { 31 + /// Build a header for the given signing key, setting `alg` to match the 32 + /// curve and `typ` to `"JWT"`. 33 + pub fn for_signing_key(key: &AnySigningKey) -> Self { 34 + Self { 35 + alg: key.jwt_alg().to_string(), 36 + typ: "JWT".to_string(), 37 + } 38 + } 39 + } 40 + 41 + /// Atproto service-auth JWT claims. 42 + /// 43 + /// Fields match the atproto inter-service authentication spec: 44 + /// <https://atproto.com/specs/xrpc#inter-service-authentication>. `nbf` is 45 + /// deliberately omitted — the spec does not require it and some servers 46 + /// reject unexpected claims. 47 + /// 48 + /// **Field names are wire-format-critical:** `iss`, `aud`, `exp`, `iat`, 49 + /// `lxm`, `jti` are the exact JSON keys atproto labelers expect. Do NOT 50 + /// rename without adding `#[serde(rename = "...")]` attributes. 51 + #[derive(Debug, Clone, Serialize, Deserialize)] 52 + pub struct JwtClaims { 53 + /// Issuer DID (e.g., `did:web:127.0.0.1%3A5000`). 54 + pub iss: String, 55 + /// Audience — the target service's DID, bare (no `#fragment`). 56 + pub aud: String, 57 + /// Expiration, UNIX seconds. 58 + pub exp: i64, 59 + /// Issued-at, UNIX seconds. 60 + pub iat: i64, 61 + /// Lexicon method NSID the token authorizes (e.g., 62 + /// `com.atproto.moderation.createReport`). 63 + pub lxm: String, 64 + /// Random nonce to prevent replay — hex string, 32 chars (16 bytes). 65 + pub jti: String, 66 + } 67 + 68 + /// Errors from JWT encode/decode. 69 + /// 70 + /// **Not user-rendered:** these errors only surface inside tests and 71 + /// library helpers. They deliberately do NOT derive `miette::Diagnostic` 72 + /// with stable codes — the stage converts any failure into a 73 + /// `CreateReportStageError::Transport` or a specific check SpecViolation 74 + /// before rendering. If a future caller needs one of these variants 75 + /// rendered to the user, they must wrap it in a stage-local diagnostic 76 + /// with a proper `code = "labeler::..."` string. 77 + #[derive(Debug, Error)] 78 + pub enum JwtError { 79 + /// Compact form was not three `.`-separated base64url segments. 80 + #[error("malformed compact JWT: expected three segments")] 81 + MalformedCompact, 82 + /// A base64url segment failed to decode. 83 + #[error("base64url decode failed for {segment}")] 84 + Base64Decode { 85 + /// Which segment failed: "header", "claims", or "signature". 86 + segment: &'static str, 87 + /// Underlying base64 error. 88 + #[source] 89 + source: base64::DecodeError, 90 + }, 91 + /// A segment decoded to valid bytes but invalid JSON. 92 + #[error("JSON decode failed for {segment}")] 93 + JsonDecode { 94 + /// Which segment failed: "header" or "claims". 95 + segment: &'static str, 96 + /// Underlying serde_json error. 97 + #[source] 98 + source: serde_json::Error, 99 + }, 100 + /// JSON serialization of header or claims failed (should not happen for 101 + /// well-formed structs). 102 + #[error("JSON encode failed")] 103 + JsonEncode(#[from] serde_json::Error), 104 + /// Signature was not exactly 64 bytes. 105 + #[error("signature was {actual} bytes; expected 64")] 106 + SignatureLength { 107 + /// Actual length in bytes. 108 + actual: usize, 109 + }, 110 + /// The algorithm identifier in the header is not recognized. 111 + #[error("unsupported JWT alg `{alg}` (expected ES256 or ES256K)")] 112 + UnsupportedAlg { 113 + /// The unrecognized algorithm string. 114 + alg: String, 115 + }, 116 + /// Underlying ECDSA verification failure (e.g., curve mismatch). 117 + #[error("signature verification failed")] 118 + SignatureVerify(#[from] AnySignatureError), 119 + } 120 + 121 + /// Encode a JWT in compact form: `base64url(header).base64url(claims).base64url(signature)`. 122 + /// 123 + /// Signs the concatenation `header_b64 + "." + claims_b64` with SHA-256 124 + /// prehash under the supplied key. Returns the full compact token string. 125 + pub fn encode_compact( 126 + header: &JwtHeader, 127 + claims: &JwtClaims, 128 + signer: &AnySigningKey, 129 + ) -> Result<String, JwtError> { 130 + let header_json = serde_json::to_vec(header)?; 131 + let claims_json = serde_json::to_vec(claims)?; 132 + let header_b64 = URL_SAFE_NO_PAD.encode(&header_json); 133 + let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json); 134 + let signing_input = format!("{header_b64}.{claims_b64}"); 135 + let sig = signer.sign(signing_input.as_bytes()); 136 + let sig_bytes = AnySigningKey::signature_to_jws_bytes(&sig); 137 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes); 138 + Ok(format!("{header_b64}.{claims_b64}.{sig_b64}")) 139 + } 140 + 141 + /// Decode a compact JWT into `(header, claims, signature_bytes)`. 142 + /// 143 + /// Does NOT verify the signature — use `verify_compact` for that. This helper 144 + /// is primarily for test round-tripping and for negative-test assertions 145 + /// (e.g., "the minted token has the expected `alg` header"). 146 + pub fn decode_compact(token: &str) -> Result<(JwtHeader, JwtClaims, Vec<u8>), JwtError> { 147 + let mut parts = token.splitn(3, '.'); 148 + let header_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 149 + let claims_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 150 + let sig_b64 = parts.next().ok_or(JwtError::MalformedCompact)?; 151 + if parts.next().is_some() { 152 + return Err(JwtError::MalformedCompact); 153 + } 154 + let header_bytes = 155 + URL_SAFE_NO_PAD 156 + .decode(header_b64) 157 + .map_err(|source| JwtError::Base64Decode { 158 + segment: "header", 159 + source, 160 + })?; 161 + let claims_bytes = 162 + URL_SAFE_NO_PAD 163 + .decode(claims_b64) 164 + .map_err(|source| JwtError::Base64Decode { 165 + segment: "claims", 166 + source, 167 + })?; 168 + let sig_bytes = URL_SAFE_NO_PAD 169 + .decode(sig_b64) 170 + .map_err(|source| JwtError::Base64Decode { 171 + segment: "signature", 172 + source, 173 + })?; 174 + let header: JwtHeader = 175 + serde_json::from_slice(&header_bytes).map_err(|source| JwtError::JsonDecode { 176 + segment: "header", 177 + source, 178 + })?; 179 + let claims: JwtClaims = 180 + serde_json::from_slice(&claims_bytes).map_err(|source| JwtError::JsonDecode { 181 + segment: "claims", 182 + source, 183 + })?; 184 + Ok((header, claims, sig_bytes)) 185 + } 186 + 187 + /// Verify a compact JWT against the given verifying key. Does NOT check 188 + /// claim values (exp/aud/lxm) — that is the labeler's job in production, 189 + /// or the stage's assertion job in tests. Only verifies the signature. 190 + pub fn verify_compact( 191 + token: &str, 192 + vkey: &AnyVerifyingKey, 193 + ) -> Result<(JwtHeader, JwtClaims), JwtError> { 194 + let (header, claims, sig_bytes) = decode_compact(token)?; 195 + let expected_alg = match vkey { 196 + AnyVerifyingKey::K256(_) => "ES256K", 197 + AnyVerifyingKey::P256(_) => "ES256", 198 + }; 199 + if header.alg != expected_alg { 200 + return Err(JwtError::UnsupportedAlg { 201 + alg: header.alg.clone(), 202 + }); 203 + } 204 + if sig_bytes.len() != 64 { 205 + return Err(JwtError::SignatureLength { 206 + actual: sig_bytes.len(), 207 + }); 208 + } 209 + let sig_array: [u8; 64] = sig_bytes.as_slice().try_into().expect("len checked above"); 210 + let any_sig = match vkey { 211 + AnyVerifyingKey::K256(_) => { 212 + let sig = k256::ecdsa::Signature::from_bytes(&sig_array.into()).map_err(|_| { 213 + JwtError::SignatureLength { 214 + actual: sig_bytes.len(), 215 + } 216 + })?; 217 + AnySignature::K256(sig) 218 + } 219 + AnyVerifyingKey::P256(_) => { 220 + let sig = p256::ecdsa::Signature::from_bytes(&sig_array.into()).map_err(|_| { 221 + JwtError::SignatureLength { 222 + actual: sig_bytes.len(), 223 + } 224 + })?; 225 + AnySignature::P256(sig) 226 + } 227 + }; 228 + // Recompute the signing input and verify. 229 + let dot = token 230 + .rfind('.') 231 + .expect("three-segment token has a last dot"); 232 + let signing_input = &token[..dot]; 233 + use sha2::{Digest, Sha256}; 234 + let prehash: [u8; 32] = Sha256::digest(signing_input.as_bytes()).into(); 235 + vkey.verify_prehash(&prehash, &any_sig)?; 236 + Ok((header, claims)) 237 + } 238 + 239 + #[cfg(test)] 240 + mod tests { 241 + use super::*; 242 + use k256::ecdsa::SigningKey as K256SigningKey; 243 + use p256::ecdsa::SigningKey as P256SigningKey; 244 + 245 + #[test] 246 + fn encode_decode_roundtrip_k256() { 247 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 248 + let vkey = key.verifying_key(); 249 + let header = JwtHeader::for_signing_key(&key); 250 + let claims = JwtClaims { 251 + iss: "did:web:127.0.0.1%3A5000".to_string(), 252 + aud: "did:plc:test".to_string(), 253 + exp: 2000000000, 254 + iat: 1700000000, 255 + lxm: "com.atproto.moderation.createReport".to_string(), 256 + jti: "0123456789abcdef".to_string(), 257 + }; 258 + 259 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 260 + let (decoded_header, decoded_claims) = 261 + verify_compact(&token, &vkey).expect("verify succeeds"); 262 + 263 + assert_eq!(decoded_header.alg, "ES256K"); 264 + assert_eq!(decoded_claims.iss, claims.iss); 265 + assert_eq!(decoded_claims.aud, claims.aud); 266 + } 267 + 268 + #[test] 269 + fn encode_decode_roundtrip_p256() { 270 + let key = AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 271 + let vkey = key.verifying_key(); 272 + let header = JwtHeader::for_signing_key(&key); 273 + let claims = JwtClaims { 274 + iss: "did:web:example.com".to_string(), 275 + aud: "did:plc:test".to_string(), 276 + exp: 2000000000, 277 + iat: 1700000000, 278 + lxm: "com.atproto.moderation.createReport".to_string(), 279 + jti: "fedcba9876543210".to_string(), 280 + }; 281 + 282 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 283 + let (decoded_header, decoded_claims) = 284 + verify_compact(&token, &vkey).expect("verify succeeds"); 285 + 286 + assert_eq!(decoded_header.alg, "ES256"); 287 + assert_eq!(decoded_claims.aud, claims.aud); 288 + } 289 + 290 + #[test] 291 + fn encode_decode_roundtrip_tampered_claims_fails() { 292 + let key = AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 293 + let vkey = key.verifying_key(); 294 + let header = JwtHeader::for_signing_key(&key); 295 + let claims = JwtClaims { 296 + iss: "did:web:127.0.0.1%3A5000".to_string(), 297 + aud: "did:plc:test".to_string(), 298 + exp: 2000000000, 299 + iat: 1700000000, 300 + lxm: "com.atproto.moderation.createReport".to_string(), 301 + jti: "0123456789abcdef".to_string(), 302 + }; 303 + 304 + let token = encode_compact(&header, &claims, &key).expect("encode succeeds"); 305 + let parts: Vec<&str> = token.split('.').collect(); 306 + assert_eq!(parts.len(), 3); 307 + 308 + // Tamper with claims segment. 309 + let tampered = format!("{}.YWJj.{}", parts[0], parts[2]); 310 + let result = verify_compact(&tampered, &vkey); 311 + assert!(result.is_err()); 312 + } 313 + 314 + #[test] 315 + fn decode_compact_malformed_two_segments() { 316 + let result = decode_compact("header.claims"); 317 + assert!(matches!(result, Err(JwtError::MalformedCompact))); 318 + } 319 + 320 + #[test] 321 + fn decode_compact_malformed_four_segments() { 322 + // splitn(3, '.') will split "a.b.c.d" into ["a", "b", "c.d"], and after 323 + // consuming the first 3 parts, there's no 4th part, so this won't trigger 324 + // the MalformedCompact error. Instead, the base64 decoding of "c.d" will fail. 325 + // Test with a properly-formed JWT token structure but with 4 segments instead. 326 + let result = decode_compact("YQ.Yg.Yw.ZA"); 327 + // The splitn(3, '.') will split this into ["YQ", "Yg", "Yw.ZA"]. 328 + // After getting the first 3, there's no 4th part to trigger the extra check. 329 + // The signature "Yw.ZA" contains a dot, so base64 decode will fail. 330 + assert!(matches!( 331 + result, 332 + Err(JwtError::Base64Decode { 333 + segment: "signature", 334 + .. 335 + }) 336 + )); 337 + } 338 + 339 + #[test] 340 + fn decode_compact_invalid_base64() { 341 + let result = decode_compact("!!!.claims.sig"); 342 + assert!(matches!( 343 + result, 344 + Err(JwtError::Base64Decode { 345 + segment: "header", 346 + .. 347 + }) 348 + )); 349 + } 350 + 351 + #[test] 352 + fn verify_compact_curve_mismatch() { 353 + let k256_key = 354 + AnySigningKey::K256(K256SigningKey::from_slice(&[1u8; 32]).expect("valid seed")); 355 + let p256_key = 356 + AnySigningKey::P256(P256SigningKey::from_slice(&[2u8; 32]).expect("valid seed")); 357 + 358 + let header = JwtHeader::for_signing_key(&k256_key); 359 + let claims = JwtClaims { 360 + iss: "did:web:test".to_string(), 361 + aud: "did:plc:test".to_string(), 362 + exp: 2000000000, 363 + iat: 1700000000, 364 + lxm: "com.atproto.moderation.createReport".to_string(), 365 + jti: "0123456789abcdef".to_string(), 366 + }; 367 + 368 + let token = encode_compact(&header, &claims, &k256_key).expect("encode succeeds"); 369 + let p256_vkey = p256_key.verifying_key(); 370 + 371 + // Trying to verify a K256-signed token with a P256 key should fail. 372 + let result = verify_compact(&token, &p256_vkey); 373 + assert!(result.is_err()); 374 + } 375 + }