Skip to main content

tf_types/
bridge_gnap.rs

1//! GNAP (RFC 9635) + DPoP (RFC 9449) bridge โ€” Rust mirror of
2//! `tools/tf-types-ts/src/core/bridge-gnap.ts`.
3//!
4//! The bridge does not run an HTTP server; it provides typed shapes for
5//! a `start โ†’ continue โ†’ access` GNAP flow plus DPoP proof verification
6//! and ActorIdentity projection from a verified bound access token.
7
8use std::collections::HashMap;
9
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use base64::Engine;
12use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::bridge_oauth::{project_jwk_to_public_key, Jwk, Jwks};
18use crate::bridges::{Bridge, BridgeError, BridgeKind};
19use crate::generated::{
20    ActorIdentity, ActorIdentity_IdentityVersion, ActorType, AuthorityRoot, AuthorityRoot_Kind,
21    TrustLevel,
22};
23
24#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct GnapKeyDescriptor {
26    pub proof: String,
27    pub jwk: Jwk,
28}
29
30#[derive(Clone, Debug, Serialize, Deserialize)]
31pub struct GnapClient {
32    #[serde(skip_serializing_if = "Option::is_none", default)]
33    pub id: Option<String>,
34    pub key: GnapKeyDescriptor,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize)]
38#[serde(untagged)]
39pub enum GnapAccessRight {
40    Reference(String),
41    Object {
42        #[serde(skip_serializing_if = "Option::is_none", default)]
43        actions: Option<Vec<String>>,
44        #[serde(skip_serializing_if = "Option::is_none", default)]
45        locations: Option<Vec<String>>,
46        #[serde(skip_serializing_if = "Option::is_none", default, rename = "type")]
47        kind: Option<String>,
48    },
49}
50
51impl GnapAccessRight {
52    pub fn actions(&self) -> Vec<String> {
53        match self {
54            GnapAccessRight::Reference(s) => vec![s.clone()],
55            GnapAccessRight::Object { actions, .. } => actions.clone().unwrap_or_default(),
56        }
57    }
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct GnapAccessTokenRequest {
62    pub access: Vec<GnapAccessRight>,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66pub struct GnapGrantRequest {
67    pub client: GnapClient,
68    pub access_token: GnapAccessTokenRequest,
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct GnapAccessTokenResponse {
73    pub value: String,
74    pub bound: bool,
75    #[serde(skip_serializing_if = "Option::is_none", default)]
76    pub expires_in: Option<u64>,
77}
78
79#[derive(Clone, Debug, Serialize, Deserialize)]
80pub struct GnapGrantResponse {
81    pub access_token: GnapAccessTokenResponse,
82    #[serde(skip_serializing_if = "Option::is_none", default)]
83    pub continue_uri: Option<String>,
84}
85
86#[derive(Clone, Debug, Serialize, Deserialize)]
87pub struct GnapBridgeConfig {
88    pub bridge_id: String,
89    pub trust_domain: String,
90    pub issuer: String,
91    pub allowed_algorithms: Vec<String>,
92    pub jwks: Jwks,
93}
94
95#[derive(Clone, Debug)]
96pub struct GnapVerifiedGrant {
97    pub identity: ActorIdentity,
98    pub capabilities: Vec<String>,
99    pub client_key_thumbprint: String,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct DpopProofVerification {
104    pub ok: bool,
105    #[serde(skip_serializing_if = "Option::is_none", default)]
106    pub reason: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none", default)]
108    pub jkt_expected: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none", default)]
110    pub jkt_seen: Option<String>,
111}
112
113pub struct GnapBridge {
114    cfg: GnapBridgeConfig,
115}
116
117impl GnapBridge {
118    pub fn new(cfg: GnapBridgeConfig) -> Self {
119        GnapBridge { cfg }
120    }
121
122    pub fn build_grant_response(
123        &self,
124        req: &GnapGrantRequest,
125        token: &str,
126        finish_uri: Option<&str>,
127    ) -> Result<GnapGrantResponse, BridgeError> {
128        if req.access_token.access.is_empty() {
129            return Err(BridgeError::InvalidInput(
130                "access_token.access required".into(),
131            ));
132        }
133        if req.client.key.jwk.kty.is_empty() {
134            return Err(BridgeError::InvalidInput("client.key.jwk required".into()));
135        }
136        Ok(GnapGrantResponse {
137            access_token: GnapAccessTokenResponse {
138                value: token.into(),
139                bound: true,
140                expires_in: Some(600),
141            },
142            continue_uri: finish_uri.map(str::to_string),
143        })
144    }
145
146    pub fn verify_access_token(
147        &self,
148        token: &str,
149        request: &GnapGrantRequest,
150    ) -> Result<GnapVerifiedGrant, BridgeError> {
151        if token.is_empty() {
152            return Err(BridgeError::InvalidInput("missing access token".into()));
153        }
154        let header = decode_header(token)
155            .map_err(|e| BridgeError::Rejected(format!("malformed JWT: {}", e)))?;
156        let alg_name = format!("{:?}", header.alg);
157        if !self
158            .cfg
159            .allowed_algorithms
160            .iter()
161            .any(|a| a.eq_ignore_ascii_case(&alg_name))
162        {
163            return Err(BridgeError::Rejected(format!(
164                "algorithm {} not in allow-list",
165                alg_name
166            )));
167        }
168        let kid = header
169            .kid
170            .clone()
171            .ok_or_else(|| BridgeError::Rejected("JWT header missing kid".into()))?;
172        let jwk = self
173            .cfg
174            .jwks
175            .keys
176            .iter()
177            .find(|k| k.kid.as_deref() == Some(&kid))
178            .ok_or_else(|| BridgeError::Rejected(format!("no JWK with kid {}", kid)))?;
179        let key = decoding_key_for_jwk(jwk)?;
180        let mut validation = Validation::new(header.alg);
181        validation.set_issuer(&[self.cfg.issuer.as_str()]);
182        validation.algorithms = vec![header.alg];
183        validation.validate_aud = false;
184        let data = decode::<HashMap<String, Value>>(token, &key, &validation).map_err(|e| {
185            BridgeError::Rejected(format!("GNAP access token verify failed: {}", e))
186        })?;
187        let claims = data.claims;
188        let expected_jkt = jwk_thumbprint(&request.client.key.jwk)?;
189        if let Some(cnf) = claims.get("cnf").and_then(|v| v.as_object()) {
190            if let Some(jkt) = cnf.get("jkt").and_then(|v| v.as_str()) {
191                if jkt != expected_jkt {
192                    return Err(BridgeError::Rejected(
193                        "access token cnf.jkt does not match client.key".into(),
194                    ));
195                }
196            }
197        }
198        let subject = claims
199            .get("sub")
200            .and_then(|v| v.as_str())
201            .unwrap_or("anonymous")
202            .to_string();
203        let actor_type_str = claims
204            .get("tf_actor_type")
205            .and_then(|v| v.as_str())
206            .unwrap_or("agent")
207            .to_string();
208        let actor_type = match actor_type_str.as_str() {
209            "human" => ActorType::Human,
210            "agent" => ActorType::Agent,
211            "device" => ActorType::Device,
212            "service" => ActorType::Service,
213            "site" => ActorType::Site,
214            "organization" => ActorType::Organization,
215            other => {
216                return Err(BridgeError::Rejected(format!(
217                    "unsupported tf_actor_type: {}",
218                    other
219                )))
220            }
221        };
222        let actor_id = format!(
223            "tf:actor:{}:{}/{}",
224            actor_type_str,
225            self.cfg.trust_domain,
226            url_encode(&subject)
227        );
228        let actions: Vec<String> = request
229            .access_token
230            .access
231            .iter()
232            .flat_map(|r| r.actions())
233            .collect();
234        let identity = ActorIdentity {
235            identity_version: ActorIdentity_IdentityVersion::V1,
236            actor_id,
237            actor_type,
238            instance_id: None,
239            public_keys: vec![project_jwk_to_public_key(&request.client.key.jwk)?],
240            trust_levels: vec![TrustLevel::T3],
241            authority_roots: vec![AuthorityRoot {
242                kind: AuthorityRoot_Kind::Organization,
243                id: self.cfg.issuer.clone(),
244            }],
245            attestations: None,
246            valid_from: claims
247                .get("iat")
248                .and_then(|v| v.as_u64())
249                .map(timestamp)
250                .unwrap_or_else(|| timestamp(now_unix())),
251            valid_until: claims.get("exp").and_then(|v| v.as_u64()).map(timestamp),
252            revocation_ref: None,
253            signature: None,
254        };
255        Ok(GnapVerifiedGrant {
256            identity,
257            capabilities: actions,
258            client_key_thumbprint: expected_jkt,
259        })
260    }
261
262    pub fn verify_dpop_proof(
263        &self,
264        proof_jwt: &str,
265        htm: &str,
266        htu: &str,
267        access_token_hash: Option<&str>,
268        expected_jkt: &str,
269    ) -> DpopProofVerification {
270        if proof_jwt.is_empty() {
271            return DpopProofVerification {
272                ok: false,
273                reason: Some("missing DPoP proof".into()),
274                jkt_expected: Some(expected_jkt.into()),
275                jkt_seen: None,
276            };
277        }
278        let parts: Vec<&str> = proof_jwt.split('.').collect();
279        if parts.len() != 3 {
280            return DpopProofVerification {
281                ok: false,
282                reason: Some("DPoP proof not a JWT".into()),
283                jkt_expected: Some(expected_jkt.into()),
284                jkt_seen: None,
285            };
286        }
287        let header_bytes = match URL_SAFE_NO_PAD.decode(parts[0]) {
288            Ok(b) => b,
289            Err(e) => {
290                return DpopProofVerification {
291                    ok: false,
292                    reason: Some(format!("DPoP header decode: {}", e)),
293                    jkt_expected: Some(expected_jkt.into()),
294                    jkt_seen: None,
295                }
296            }
297        };
298        let header: Value = match serde_json::from_slice(&header_bytes) {
299            Ok(v) => v,
300            Err(e) => {
301                return DpopProofVerification {
302                    ok: false,
303                    reason: Some(format!("DPoP header parse: {}", e)),
304                    jkt_expected: Some(expected_jkt.into()),
305                    jkt_seen: None,
306                }
307            }
308        };
309        if header.get("typ").and_then(|v| v.as_str()) != Some("dpop+jwt") {
310            return DpopProofVerification {
311                ok: false,
312                reason: Some(format!("DPoP typ {:?} is not dpop+jwt", header.get("typ"))),
313                jkt_expected: Some(expected_jkt.into()),
314                jkt_seen: None,
315            };
316        }
317        let jwk_value = match header.get("jwk") {
318            Some(v) => v,
319            None => {
320                return DpopProofVerification {
321                    ok: false,
322                    reason: Some("DPoP header missing jwk".into()),
323                    jkt_expected: Some(expected_jkt.into()),
324                    jkt_seen: None,
325                }
326            }
327        };
328        let jwk: Jwk = match serde_json::from_value(jwk_value.clone()) {
329            Ok(v) => v,
330            Err(e) => {
331                return DpopProofVerification {
332                    ok: false,
333                    reason: Some(format!("DPoP jwk parse: {}", e)),
334                    jkt_expected: Some(expected_jkt.into()),
335                    jkt_seen: None,
336                }
337            }
338        };
339        let jkt = match jwk_thumbprint(&jwk) {
340            Ok(s) => s,
341            Err(e) => {
342                return DpopProofVerification {
343                    ok: false,
344                    reason: Some(format!("DPoP thumbprint: {}", e)),
345                    jkt_expected: Some(expected_jkt.into()),
346                    jkt_seen: None,
347                }
348            }
349        };
350        if jkt != expected_jkt {
351            return DpopProofVerification {
352                ok: false,
353                reason: Some("jkt mismatch".into()),
354                jkt_expected: Some(expected_jkt.into()),
355                jkt_seen: Some(jkt),
356            };
357        }
358        let key = match decoding_key_for_jwk(&jwk) {
359            Ok(k) => k,
360            Err(e) => {
361                return DpopProofVerification {
362                    ok: false,
363                    reason: Some(format!("DPoP key build: {}", e)),
364                    jkt_expected: Some(expected_jkt.into()),
365                    jkt_seen: Some(jkt),
366                }
367            }
368        };
369        let alg_name = header
370            .get("alg")
371            .and_then(|v| v.as_str())
372            .unwrap_or("ES256");
373        let alg = match crate::bridge_oauth::parse_algorithm(alg_name) {
374            Ok(a) => a,
375            Err(e) => {
376                return DpopProofVerification {
377                    ok: false,
378                    reason: Some(format!("DPoP alg parse: {}", e)),
379                    jkt_expected: Some(expected_jkt.into()),
380                    jkt_seen: Some(jkt),
381                }
382            }
383        };
384        let mut validation = Validation::new(alg);
385        validation.required_spec_claims.clear();
386        validation.validate_exp = false;
387        validation.validate_aud = false;
388        validation.algorithms = vec![alg];
389        let payload = match decode::<HashMap<String, Value>>(proof_jwt, &key, &validation) {
390            Ok(d) => d.claims,
391            Err(e) => {
392                return DpopProofVerification {
393                    ok: false,
394                    reason: Some(format!("DPoP signature verify failed: {}", e)),
395                    jkt_expected: Some(expected_jkt.into()),
396                    jkt_seen: Some(jkt),
397                }
398            }
399        };
400        if payload.get("htm").and_then(|v| v.as_str()) != Some(htm) {
401            return DpopProofVerification {
402                ok: false,
403                reason: Some(format!(
404                    "DPoP htm {:?} does not match expected {}",
405                    payload.get("htm"),
406                    htm
407                )),
408                jkt_expected: Some(expected_jkt.into()),
409                jkt_seen: Some(jkt),
410            };
411        }
412        if payload.get("htu").and_then(|v| v.as_str()) != Some(htu) {
413            return DpopProofVerification {
414                ok: false,
415                reason: Some(format!(
416                    "DPoP htu {:?} does not match expected {}",
417                    payload.get("htu"),
418                    htu
419                )),
420                jkt_expected: Some(expected_jkt.into()),
421                jkt_seen: Some(jkt),
422            };
423        }
424        if let Some(expected_ath) = access_token_hash {
425            if payload.get("ath").and_then(|v| v.as_str()) != Some(expected_ath) {
426                return DpopProofVerification {
427                    ok: false,
428                    reason: Some("DPoP ath does not match expected access-token hash".into()),
429                    jkt_expected: Some(expected_jkt.into()),
430                    jkt_seen: Some(jkt),
431                };
432            }
433        }
434        if !payload.get("iat").map(|v| v.is_number()).unwrap_or(false) {
435            return DpopProofVerification {
436                ok: false,
437                reason: Some("DPoP missing iat".into()),
438                jkt_expected: Some(expected_jkt.into()),
439                jkt_seen: Some(jkt),
440            };
441        }
442        DpopProofVerification {
443            ok: true,
444            reason: None,
445            jkt_expected: Some(expected_jkt.into()),
446            jkt_seen: Some(jkt),
447        }
448    }
449}
450
451impl Bridge for GnapBridge {
452    fn bridge_id(&self) -> &str {
453        &self.cfg.bridge_id
454    }
455    fn kind(&self) -> BridgeKind {
456        BridgeKind::Gnap
457    }
458    fn trust_domain(&self) -> &str {
459        &self.cfg.trust_domain
460    }
461}
462
463fn decoding_key_for_jwk(jwk: &Jwk) -> Result<DecodingKey, BridgeError> {
464    match jwk.kty.as_str() {
465        "EC" => {
466            let x = jwk
467                .x
468                .as_ref()
469                .ok_or_else(|| BridgeError::InvalidInput("EC JWK missing x".into()))?;
470            let y = jwk
471                .y
472                .as_ref()
473                .ok_or_else(|| BridgeError::InvalidInput("EC JWK missing y".into()))?;
474            DecodingKey::from_ec_components(x, y)
475                .map_err(|e| BridgeError::InvalidInput(format!("bad EC components: {}", e)))
476        }
477        "RSA" => {
478            let n = jwk
479                .n
480                .as_ref()
481                .ok_or_else(|| BridgeError::InvalidInput("RSA JWK missing n".into()))?;
482            let e = jwk
483                .e
484                .as_ref()
485                .ok_or_else(|| BridgeError::InvalidInput("RSA JWK missing e".into()))?;
486            DecodingKey::from_rsa_components(n, e)
487                .map_err(|e| BridgeError::InvalidInput(format!("bad RSA components: {}", e)))
488        }
489        "OKP" => {
490            let x = jwk
491                .x
492                .as_ref()
493                .ok_or_else(|| BridgeError::InvalidInput("OKP JWK missing x".into()))?;
494            DecodingKey::from_ed_components(x)
495                .map_err(|e| BridgeError::InvalidInput(format!("bad OKP components: {}", e)))
496        }
497        other => Err(BridgeError::InvalidInput(format!(
498            "unsupported kty {}",
499            other
500        ))),
501    }
502}
503
504/// Compute the RFC 7638 thumbprint of a JWK using SHA-256. The mandatory
505/// member set is per RFC 7638 ยง3.2: kty + key-specific set in
506/// lexicographic order.
507pub fn jwk_thumbprint(jwk: &Jwk) -> Result<String, BridgeError> {
508    let canonical = match jwk.kty.as_str() {
509        "EC" => {
510            let crv = jwk
511                .crv
512                .as_ref()
513                .ok_or_else(|| BridgeError::InvalidInput("EC JWK missing crv".into()))?;
514            let x = jwk
515                .x
516                .as_ref()
517                .ok_or_else(|| BridgeError::InvalidInput("EC JWK missing x".into()))?;
518            let y = jwk
519                .y
520                .as_ref()
521                .ok_or_else(|| BridgeError::InvalidInput("EC JWK missing y".into()))?;
522            format!(
523                "{{\"crv\":\"{}\",\"kty\":\"{}\",\"x\":\"{}\",\"y\":\"{}\"}}",
524                crv, jwk.kty, x, y
525            )
526        }
527        "OKP" => {
528            let crv = jwk
529                .crv
530                .as_ref()
531                .ok_or_else(|| BridgeError::InvalidInput("OKP JWK missing crv".into()))?;
532            let x = jwk
533                .x
534                .as_ref()
535                .ok_or_else(|| BridgeError::InvalidInput("OKP JWK missing x".into()))?;
536            format!(
537                "{{\"crv\":\"{}\",\"kty\":\"{}\",\"x\":\"{}\"}}",
538                crv, jwk.kty, x
539            )
540        }
541        "RSA" => {
542            let n = jwk
543                .n
544                .as_ref()
545                .ok_or_else(|| BridgeError::InvalidInput("RSA JWK missing n".into()))?;
546            let e = jwk
547                .e
548                .as_ref()
549                .ok_or_else(|| BridgeError::InvalidInput("RSA JWK missing e".into()))?;
550            format!(
551                "{{\"e\":\"{}\",\"kty\":\"{}\",\"n\":\"{}\"}}",
552                e, jwk.kty, n
553            )
554        }
555        other => {
556            return Err(BridgeError::Unsupported(format!(
557                "unsupported kty for thumbprint: {}",
558                other
559            )))
560        }
561    };
562    let digest: [u8; 32] = Sha256::digest(canonical.as_bytes()).into();
563    Ok(URL_SAFE_NO_PAD.encode(digest))
564}
565
566fn timestamp(t: u64) -> String {
567    let secs = t as i64;
568    let (year, month, day, hour, minute, second) = secs_to_ymdhms(secs);
569    format!(
570        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
571        year, month, day, hour, minute, second
572    )
573}
574
575fn now_unix() -> u64 {
576    std::time::SystemTime::now()
577        .duration_since(std::time::UNIX_EPOCH)
578        .unwrap_or_default()
579        .as_secs()
580}
581
582fn secs_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
583    let days = secs.div_euclid(86_400);
584    let time = secs.rem_euclid(86_400);
585    let hour = (time / 3600) as u32;
586    let minute = ((time % 3600) / 60) as u32;
587    let second = (time % 60) as u32;
588    let z = days + 719_468;
589    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
590    let doe = (z - era * 146_097) as u64;
591    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
592    let y = yoe as i64 + era * 400;
593    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
594    let mp = (5 * doy + 2) / 153;
595    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
596    let m = if mp < 10 {
597        (mp + 3) as u32
598    } else {
599        (mp - 9) as u32
600    };
601    let year = if m <= 2 { y + 1 } else { y };
602    (year as i32, m, d, hour, minute, second)
603}
604
605fn url_encode(s: &str) -> String {
606    let mut out = String::with_capacity(s.len());
607    for b in s.bytes() {
608        match b {
609            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
610                out.push(b as char);
611            }
612            _ => out.push_str(&format!("%{:02X}", b)),
613        }
614    }
615    out
616}