An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

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

feat(identity-wallet): DPoP keypair, proof builder, and OAuth Keychain helpers (MM-149 phase 3)

authored by

Malpercio and committed by
Tangled
e6d80078 18fd44ff

+314
+314
apps/identity-wallet/src-tauri/src/oauth.rs
··· 3 3 // Types: AppState, PendingOAuthFlow, OAuthSession, CallbackParams (Functional Core) 4 4 // handle_deep_link: Imperative Shell (reads OS callback, routes to pending channel) 5 5 6 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 7 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 8 + #[allow(unused_imports)] 9 + use p256::elliptic_curve::sec1::ToEncodedPoint; 10 + use sha2::{Digest, Sha256}; 6 11 use std::sync::Mutex; 12 + use std::time::{SystemTime, UNIX_EPOCH}; 7 13 use tracing; 14 + use uuid::Uuid; 8 15 9 16 // ── Shared state ────────────────────────────────────────────────────────────── 10 17 ··· 36 43 } 37 44 } 38 45 46 + // ── OAuth error ─────────────────────────────────────────────────────────────── 47 + 48 + /// Error type for all OAuth-related operations. 49 + /// 50 + /// Variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` to match the 51 + /// existing error pattern (`CreateAccountError`, `DeviceKeyError`, etc.). 52 + #[derive(Debug, thiserror::Error, serde::Serialize)] 53 + #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "code")] 54 + pub enum OAuthError { 55 + #[error("DPoP keypair generation failed")] 56 + DpopKeyGenFailed, 57 + #[error("DPoP keypair is invalid")] 58 + DpopKeyInvalid, 59 + #[error("DPoP proof construction failed")] 60 + DpopProofFailed, 61 + #[error("Keychain error")] 62 + KeychainError, 63 + #[error("State mismatch in OAuth callback")] 64 + StateMismatch, 65 + #[error("OAuth callback abandoned")] 66 + CallbackAbandoned, 67 + #[error("PAR request failed")] 68 + ParFailed, 69 + #[error("Token exchange failed")] 70 + TokenExchangeFailed, 71 + #[error("Token refresh failed")] 72 + TokenRefreshFailed, 73 + #[error("Not authenticated")] 74 + NotAuthenticated, 75 + } 76 + 77 + // ── DPoP keypair ───────────────────────────────────────────────────────────── 78 + 79 + /// A P-256 keypair used to produce DPoP proofs. 80 + /// 81 + /// The private key scalar (32 bytes) is persisted in the iOS Keychain under 82 + /// `"oauth-dpop-key-priv"`. The same key is used for all DPoP proofs across 83 + /// app sessions — it is never rotated by this implementation. 84 + pub struct DPoPKeypair { 85 + signing_key: SigningKey, 86 + } 87 + 88 + impl DPoPKeypair { 89 + /// Load the DPoP keypair from Keychain, or generate and persist a new one. 90 + pub fn get_or_create() -> Result<Self, OAuthError> { 91 + if let Some(private_bytes) = crate::keychain::load_dpop_key() { 92 + let signing_key = SigningKey::from_slice(&private_bytes) 93 + .map_err(|_| OAuthError::DpopKeyInvalid)?; 94 + return Ok(Self { signing_key }); 95 + } 96 + 97 + // Generate a new P-256 keypair via the shared crypto crate. 98 + let keypair = crypto::generate_p256_keypair().map_err(|_| OAuthError::DpopKeyGenFailed)?; 99 + // `private_key_bytes` is `Zeroizing<[u8; 32]>`, which derefs directly to `[u8; 32]`. 100 + let private_bytes: [u8; 32] = *keypair.private_key_bytes; 101 + 102 + crate::keychain::store_dpop_key(&private_bytes) 103 + .map_err(|_| OAuthError::KeychainError)?; 104 + 105 + let signing_key = SigningKey::from_slice(&private_bytes) 106 + .map_err(|_| OAuthError::DpopKeyInvalid)?; 107 + Ok(Self { signing_key }) 108 + } 109 + 110 + /// Build the public JWK for this keypair (EC, P-256, kty/crv/x/y only — no private fields). 111 + /// 112 + /// The relay's validator expects exactly: `{"kty":"EC","crv":"P-256","x":"<b64url>","y":"<b64url>"}`. 113 + pub fn public_jwk(&self) -> serde_json::Value { 114 + let verifying_key = self.signing_key.verifying_key(); 115 + let point = verifying_key.to_encoded_point(false); // false = uncompressed: 04 || x || y 116 + let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 uncompressed point has x")); 117 + let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 uncompressed point has y")); 118 + serde_json::json!({ 119 + "kty": "EC", 120 + "crv": "P-256", 121 + "x": x, 122 + "y": y, 123 + }) 124 + } 125 + 126 + /// Compute the RFC 7638 JWK thumbprint: `base64url(SHA-256(canonical_jwk_json))`. 127 + /// 128 + /// The canonical JSON uses lexicographically-sorted keys (crv, kty, x, y) per RFC 7638 §3.2. 129 + /// This matches the relay's `jwk_thumbprint()` function in `crates/relay/src/auth/dpop.rs`. 130 + pub fn public_jwk_thumbprint(&self) -> String { 131 + let jwk = self.public_jwk(); 132 + // Canonical member set per RFC 7638 §3.2 — lexicographic order for EC keys. 133 + // serde_json internally represents JSON objects as BTreeMap, which serializes 134 + // keys in lexicographic order. This is what RFC 7638 §3.2 requires for the 135 + // canonical JSON. The key ordering here (crv < kty < x < y) is lexicographic. 136 + let canonical = serde_json::json!({ 137 + "crv": jwk["crv"], 138 + "kty": jwk["kty"], 139 + "x": jwk["x"], 140 + "y": jwk["y"], 141 + }); 142 + let canonical_json = serde_json::to_string(&canonical) 143 + .expect("canonical JWK serialization is infallible for known types"); 144 + let hash = Sha256::digest(canonical_json.as_bytes()); 145 + URL_SAFE_NO_PAD.encode(hash) 146 + } 147 + 148 + /// Build a DPoP proof JWT for the given HTTP method, URL, and optional claims. 149 + /// 150 + /// - `htm`: HTTP method in uppercase, e.g. `"POST"` or `"GET"` 151 + /// - `htu`: Full target URL without query string, e.g. `"https://relay.ezpds.com/oauth/token"` 152 + /// - `nonce`: Server-issued nonce from a prior `use_dpop_nonce` 400 response (if any) 153 + /// - `ath`: `base64url(SHA-256(access_token_ascii))` — required for resource requests; None for token requests 154 + /// 155 + /// Proof format: `base64url(header_json)`.`base64url(claims_json)`.`base64url(sig)` 156 + /// where sig is the raw 64-byte R||S P-256 ECDSA signature of the signing input. 157 + pub fn make_proof( 158 + &self, 159 + htm: &str, 160 + htu: &str, 161 + nonce: Option<&str>, 162 + ath: Option<&str>, 163 + ) -> Result<String, OAuthError> { 164 + let jwk = self.public_jwk(); 165 + 166 + // Header JSON. 167 + let header = serde_json::json!({ 168 + "typ": "dpop+jwt", 169 + "alg": "ES256", 170 + "jwk": jwk, 171 + }); 172 + let header_b64 = URL_SAFE_NO_PAD.encode( 173 + serde_json::to_vec(&header).map_err(|_| OAuthError::DpopProofFailed)?, 174 + ); 175 + 176 + // Claims JSON. 177 + let iat = SystemTime::now() 178 + .duration_since(UNIX_EPOCH) 179 + .map_err(|_| OAuthError::DpopProofFailed)? 180 + .as_secs() as i64; 181 + 182 + let mut claims = serde_json::json!({ 183 + "jti": Uuid::new_v4().to_string(), 184 + "htm": htm, 185 + "htu": htu, 186 + "iat": iat, 187 + }); 188 + 189 + if let Some(n) = nonce { 190 + claims["nonce"] = serde_json::Value::String(n.to_string()); 191 + } 192 + if let Some(a) = ath { 193 + claims["ath"] = serde_json::Value::String(a.to_string()); 194 + } 195 + 196 + let claims_b64 = URL_SAFE_NO_PAD.encode( 197 + serde_json::to_vec(&claims).map_err(|_| OAuthError::DpopProofFailed)?, 198 + ); 199 + 200 + // Sign `header_b64.claims_b64` bytes with P-256/SHA-256. 201 + let signing_input = format!("{header_b64}.{claims_b64}"); 202 + let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); 203 + // Normalize to low-S (consistent with the rest of the codebase, even though 204 + // the relay's DPoP validator does not require it — low-S is harmless and keeps 205 + // key usage consistent with ATProto expectations). 206 + let signature = signature.normalize_s().unwrap_or(signature); 207 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes().as_slice()); 208 + 209 + Ok(format!("{signing_input}.{sig_b64}")) 210 + } 211 + 212 + /// Compute `base64url(SHA-256(access_token))` — the `ath` claim for resource requests. 213 + pub fn compute_ath(access_token: &str) -> String { 214 + let hash = Sha256::digest(access_token.as_bytes()); 215 + URL_SAFE_NO_PAD.encode(hash) 216 + } 217 + } 218 + 39 219 // ── Pending flow (stub — filled out in Phase 5) ─────────────────────────────── 40 220 41 221 /// State parked inside `AppState.pending_auth` while `start_oauth_flow` waits ··· 93 273 tracing::debug!(url = %url, "ignoring non-OAuth deep-link"); 94 274 } 95 275 } 276 + 277 + #[cfg(test)] 278 + mod tests { 279 + use super::*; 280 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 281 + use p256::ecdsa::signature::Verifier; 282 + 283 + fn decode_jwt_part(b64: &str) -> serde_json::Value { 284 + let bytes = URL_SAFE_NO_PAD.decode(b64).expect("valid base64url"); 285 + serde_json::from_slice(&bytes).expect("valid JSON") 286 + } 287 + 288 + fn split_proof(proof: &str) -> (&str, &str, &str) { 289 + let parts: Vec<&str> = proof.splitn(3, '.').collect(); 290 + assert_eq!(parts.len(), 3, "JWT must have 3 parts"); 291 + (parts[0], parts[1], parts[2]) 292 + } 293 + 294 + #[test] 295 + fn dpop_proof_header_has_required_fields() { 296 + // MM-149.AC3.1 297 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 298 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 299 + .expect("proof must build"); 300 + let (header_b64, _, _) = split_proof(&proof); 301 + let header = decode_jwt_part(header_b64); 302 + 303 + assert_eq!(header["typ"].as_str(), Some("dpop+jwt")); 304 + assert_eq!(header["alg"].as_str(), Some("ES256")); 305 + assert_eq!(header["jwk"]["kty"].as_str(), Some("EC")); 306 + assert_eq!(header["jwk"]["crv"].as_str(), Some("P-256")); 307 + assert!(header["jwk"]["x"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 308 + assert!(header["jwk"]["y"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 309 + } 310 + 311 + #[test] 312 + fn dpop_proof_claims_has_required_fields() { 313 + // MM-149.AC3.2 314 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 315 + let proof = kp.make_proof("GET", "https://example.com/xrpc/foo", None, None) 316 + .expect("proof must build"); 317 + let (_, claims_b64, _) = split_proof(&proof); 318 + let claims = decode_jwt_part(claims_b64); 319 + 320 + assert!(claims["jti"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 321 + assert_eq!(claims["htm"].as_str(), Some("GET")); 322 + assert_eq!(claims["htu"].as_str(), Some("https://example.com/xrpc/foo")); 323 + let now = std::time::SystemTime::now() 324 + .duration_since(std::time::UNIX_EPOCH) 325 + .unwrap() 326 + .as_secs() as i64; 327 + let iat = claims["iat"].as_i64().expect("iat must be integer"); 328 + assert!((now - iat).abs() < 5, "iat must be within 5 seconds of now"); 329 + } 330 + 331 + #[test] 332 + fn dpop_proof_includes_ath_when_supplied() { 333 + // MM-149.AC3.3 334 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 335 + let proof_with = kp.make_proof("GET", "https://example.com/resource", None, Some("abc123")) 336 + .expect("proof with ath must build"); 337 + let (_, claims_b64, _) = split_proof(&proof_with); 338 + let claims = decode_jwt_part(claims_b64); 339 + assert_eq!(claims["ath"].as_str(), Some("abc123"), "ath must be present"); 340 + 341 + let proof_without = kp.make_proof("GET", "https://example.com/resource", None, None) 342 + .expect("proof without ath must build"); 343 + let (_, claims_b64, _) = split_proof(&proof_without); 344 + let claims = decode_jwt_part(claims_b64); 345 + assert!(claims["ath"].is_null(), "ath must be absent when not supplied"); 346 + } 347 + 348 + #[test] 349 + fn dpop_proof_includes_nonce_when_supplied() { 350 + // MM-149.AC3.4 351 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 352 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", Some("nonce123"), None) 353 + .expect("proof with nonce must build"); 354 + let (_, claims_b64, _) = split_proof(&proof); 355 + let claims = decode_jwt_part(claims_b64); 356 + assert_eq!(claims["nonce"].as_str(), Some("nonce123"), "nonce must be present"); 357 + 358 + let proof_no = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 359 + .expect("proof without nonce must build"); 360 + let (_, claims_b64, _) = split_proof(&proof_no); 361 + let claims = decode_jwt_part(claims_b64); 362 + assert!(claims["nonce"].is_null(), "nonce must be absent when not supplied"); 363 + } 364 + 365 + #[test] 366 + fn dpop_proof_signature_verifies_against_embedded_jwk() { 367 + // MM-149.AC3.5 368 + use p256::elliptic_curve::sec1::EncodedPoint; 369 + 370 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 371 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 372 + .expect("proof must build"); 373 + let (header_b64, claims_b64, sig_b64) = split_proof(&proof); 374 + 375 + // Reconstruct verifying key from the embedded JWK. 376 + let header = decode_jwt_part(header_b64); 377 + let x_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["x"].as_str().unwrap()).unwrap(); 378 + let y_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["y"].as_str().unwrap()).unwrap(); 379 + // Build uncompressed point: 0x04 || x || y 380 + let mut point_bytes = vec![0x04u8]; 381 + point_bytes.extend_from_slice(&x_bytes); 382 + point_bytes.extend_from_slice(&y_bytes); 383 + let point = EncodedPoint::<p256::NistP256>::from_bytes(&point_bytes).expect("valid uncompressed point"); 384 + let verifying_key = p256::ecdsa::VerifyingKey::from_encoded_point(&point) 385 + .expect("valid verifying key from JWK"); 386 + 387 + // Decode the signature. 388 + let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).expect("valid base64url sig"); 389 + let signature = p256::ecdsa::Signature::from_bytes(sig_bytes.as_slice().into()) 390 + .expect("valid R||S signature bytes"); 391 + 392 + // Verify the signature over the signing input. 393 + let signing_input = format!("{header_b64}.{claims_b64}"); 394 + verifying_key.verify(signing_input.as_bytes(), &signature) 395 + .expect("signature must verify against embedded JWK"); 396 + } 397 + 398 + #[test] 399 + fn compute_ath_matches_sha256_base64url() { 400 + let ath = DPoPKeypair::compute_ath("test_access_token"); 401 + // SHA-256("test_access_token") = known value 402 + let expected = { 403 + use sha2::{Digest, Sha256}; 404 + let hash = Sha256::digest(b"test_access_token"); 405 + URL_SAFE_NO_PAD.encode(hash) 406 + }; 407 + assert_eq!(ath, expected); 408 + } 409 + }