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