Skip to main content

tf_types/
bridge_service_mesh.rs

1#![allow(clippy::doc_overindented_list_items)]
2//! Service-mesh bridge — Envoy XFCC, Istio AuthN, Linkerd l5d-client-id.
3//!
4//! This module exposes three public parser entry points used by the
5//! daemon to convert sidecar-supplied trust signals into TrustForge
6//! actor identities and proof events:
7//!
8//! * [`parse_xfcc`]              — Envoy `X-Forwarded-Client-Cert` header.
9//! * [`parse_istio_attributes`]  — Istio `x-istio-attributes` header
10//!                                 (base64 protobuf) or a JWT bearer
11//!                                 token surfacing `source.principal`.
12//! * [`parse_linkerd_client_id`] — Linkerd `l5d-client-id` header.
13//!
14//! Each parser returns a pure data struct; emission of a signed proof
15//! event is the daemon's responsibility, but a [`ProofEventStub`] is
16//! returned alongside so the daemon can stamp and sign it directly.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use crate::bridge_spiffe::{parse_spiffe_id, spiffe_to_actor_id, ParsedSpiffeId};
22use crate::bridges::{Bridge, BridgeError, BridgeKind};
23use crate::generated::{
24    ActorIdentity, ActorIdentity_IdentityVersion, ActorType, AuthorityRoot, AuthorityRoot_Kind,
25    PublicKey, PublicKey_Purpose, TrustLevel,
26};
27
28use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
29use base64::Engine as _;
30
31// ---------------------------------------------------------------------------
32// Public types
33// ---------------------------------------------------------------------------
34
35/// One decoded entry of an `X-Forwarded-Client-Cert` header. Envoy adds
36/// one per hop; the inner-most (leftmost) entry represents the original
37/// peer the bridge cares about. Field names match the upstream XFCC keys.
38#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct XfccEntry {
40    /// SPIFFE / URI SAN (XFCC `URI=`).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub uri: Option<String>,
43    /// Hex-encoded fingerprint of the leaf cert (XFCC `Hash=`).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub hash: Option<String>,
46    /// Issuer URI (XFCC `By=`). Often a SPIFFE id of the issuing CA.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub by: Option<String>,
49    /// RFC 2253 leaf subject (XFCC `Subject=`). When `Subject=` is
50    /// absent but `DNS=` is set, the parser folds the DNS list into
51    /// this field as `dns:<comma-separated>` so downstream code has
52    /// one fallback identity field to look at.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub subject: Option<String>,
55}
56
57/// Istio AuthN principal, extracted either from a base64-encoded
58/// protobuf (`x-istio-attributes`) or from a JWT carried in
59/// `Authorization: Bearer …`.
60#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
61pub struct IstioPrincipal {
62    /// Canonical SPIFFE id, e.g. `spiffe://cluster.local/ns/foo/sa/bar`.
63    pub spiffe_id: String,
64    /// Kubernetes namespace (parsed from the SPIFFE path).
65    pub namespace: String,
66}
67
68/// Linkerd `l5d-client-id` header value. Linkerd 2.x emits a SPIFFE
69/// SVID URI; this struct re-exposes the canonical id after validation.
70#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct LinkerdClient {
72    /// Verified `spiffe://…` URI from the header.
73    pub spiffe_id: String,
74}
75
76/// A pre-built proof-event payload the daemon can sign and append to
77/// the chain. The daemon fills in actor / instance / signature; the
78/// bridge only states the event type and the (canonicalised) payload.
79#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ProofEventStub {
81    /// Event type, e.g. `bridge.service_mesh.envoy.accepted`.
82    pub event_type: String,
83    /// Free-form JSON payload. The shape is stable per `event_type`.
84    pub payload: Value,
85}
86
87#[derive(Clone, Debug, Default)]
88pub struct ServiceMeshBridgeConfig {
89    pub bridge_id: String,
90    pub trust_domain: String,
91}
92
93pub struct ServiceMeshBridge {
94    cfg: ServiceMeshBridgeConfig,
95}
96
97// ---------------------------------------------------------------------------
98// Envoy XFCC parser
99// ---------------------------------------------------------------------------
100
101/// Parse a full `X-Forwarded-Client-Cert` header into one or more
102/// [`XfccEntry`]s.
103///
104/// The header is comma-separated. Each entry is a list of
105/// `Key=Value` pairs separated by `;`. Values may be unquoted (no
106/// commas/semicolons/quotes) or wrapped in `"…"` with `\\` and `\"`
107/// escapes. Mismatched quotes are a hard error.
108///
109/// Reference: <https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers#x-forwarded-client-cert>.
110pub fn parse_xfcc(header: &str) -> Result<Vec<XfccEntry>, BridgeError> {
111    let header = header.trim();
112    if header.is_empty() {
113        return Err(BridgeError::InvalidInput("empty XFCC header".into()));
114    }
115    let raw_entries = split_xfcc_entries(header)?;
116    let mut out = Vec::with_capacity(raw_entries.len());
117    for raw in raw_entries {
118        out.push(parse_xfcc_entry(&raw)?);
119    }
120    if out.is_empty() {
121        return Err(BridgeError::InvalidInput(
122            "XFCC header parsed to zero entries".into(),
123        ));
124    }
125    // Validate that at least one identifying field is present in each
126    // entry — Envoy never emits an entry with nothing but commas, so
127    // such input is always malformed. (DNS folds into `subject` so
128    // the check on `subject` catches DNS-only entries too.)
129    for (i, e) in out.iter().enumerate() {
130        if e.uri.is_none() && e.subject.is_none() && e.by.is_none() {
131            return Err(BridgeError::InvalidInput(format!(
132                "XFCC entry #{} has no URI/By/Subject/DNS fields",
133                i
134            )));
135        }
136    }
137    Ok(out)
138}
139
140/// Split the top-level comma-separated XFCC entries, honouring quoted
141/// values that may themselves contain commas.
142fn split_xfcc_entries(header: &str) -> Result<Vec<String>, BridgeError> {
143    let mut out = Vec::new();
144    let mut current = String::new();
145    let mut chars = header.chars().peekable();
146    let mut in_quotes = false;
147    while let Some(c) = chars.next() {
148        match c {
149            '\\' if in_quotes => {
150                // Preserve escape sequence verbatim — the per-entry
151                // parser strips it later.
152                current.push(c);
153                if let Some(next) = chars.next() {
154                    current.push(next);
155                }
156            }
157            '"' => {
158                in_quotes = !in_quotes;
159                current.push(c);
160            }
161            ',' if !in_quotes => {
162                let trimmed = current.trim().to_string();
163                if !trimmed.is_empty() {
164                    out.push(trimmed);
165                }
166                current.clear();
167            }
168            _ => current.push(c),
169        }
170    }
171    if in_quotes {
172        return Err(BridgeError::InvalidInput(
173            "XFCC header has mismatched quotes".into(),
174        ));
175    }
176    let trimmed = current.trim().to_string();
177    if !trimmed.is_empty() {
178        out.push(trimmed);
179    }
180    Ok(out)
181}
182
183/// Parse a single XFCC entry (the bit between commas) into an
184/// [`XfccEntry`].
185fn parse_xfcc_entry(entry: &str) -> Result<XfccEntry, BridgeError> {
186    let pairs = split_xfcc_pairs(entry)?;
187    let mut out = XfccEntry::default();
188    let mut dns: Vec<String> = Vec::new();
189    for (k, v) in pairs {
190        match k.to_ascii_lowercase().as_str() {
191            "uri" => out.uri = Some(v),
192            "hash" => out.hash = Some(v),
193            "by" => out.by = Some(v),
194            "subject" => out.subject = Some(v),
195            "dns" => dns.push(v),
196            // `Cert=` / `Chain=` are accepted but not surfaced —
197            // the daemon's TLS bridge handles chain re-validation
198            // when a chain is needed.
199            "cert" | "chain" => {}
200            // Unknown keys are ignored — the spec is permissive.
201            _ => {}
202        }
203    }
204    // Fold DNS list into `subject` if no explicit Subject was given.
205    if out.subject.is_none() && !dns.is_empty() {
206        out.subject = Some(format!("dns:{}", dns.join(",")));
207    }
208    Ok(out)
209}
210
211/// Split a single entry into `(key, value)` pairs, honouring quoted
212/// values. Returns `BridgeError::InvalidInput` for unterminated quotes
213/// or pairs missing an `=`.
214fn split_xfcc_pairs(entry: &str) -> Result<Vec<(String, String)>, BridgeError> {
215    let mut out = Vec::new();
216    let mut current = String::new();
217    let mut chars = entry.chars().peekable();
218    let mut in_quotes = false;
219    while let Some(c) = chars.next() {
220        match c {
221            '\\' if in_quotes => {
222                current.push(c);
223                if let Some(next) = chars.next() {
224                    current.push(next);
225                }
226            }
227            '"' => {
228                in_quotes = !in_quotes;
229                current.push(c);
230            }
231            ';' if !in_quotes => {
232                push_pair(&mut out, &current)?;
233                current.clear();
234            }
235            _ => current.push(c),
236        }
237    }
238    if in_quotes {
239        return Err(BridgeError::InvalidInput(
240            "XFCC entry has mismatched quotes".into(),
241        ));
242    }
243    push_pair(&mut out, &current)?;
244    Ok(out)
245}
246
247fn push_pair(out: &mut Vec<(String, String)>, raw: &str) -> Result<(), BridgeError> {
248    let raw = raw.trim();
249    if raw.is_empty() {
250        return Ok(());
251    }
252    let eq = raw
253        .find('=')
254        .ok_or_else(|| BridgeError::InvalidInput(format!("XFCC pair missing '=': {}", raw)))?;
255    let key = raw[..eq].trim().to_string();
256    if key.is_empty() {
257        return Err(BridgeError::InvalidInput("XFCC pair has empty key".into()));
258    }
259    let value = unquote_xfcc_value(raw[eq + 1..].trim())?;
260    out.push((key, value));
261    Ok(())
262}
263
264/// Strip surrounding double-quotes and decode `\\` / `\"` escapes.
265fn unquote_xfcc_value(raw: &str) -> Result<String, BridgeError> {
266    if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
267        let inner = &raw[1..raw.len() - 1];
268        let mut out = String::with_capacity(inner.len());
269        let mut chars = inner.chars().peekable();
270        while let Some(c) = chars.next() {
271            if c == '\\' {
272                match chars.next() {
273                    Some(esc @ ('"' | '\\')) => out.push(esc),
274                    Some(other) => {
275                        // Unknown escapes pass through verbatim.
276                        out.push('\\');
277                        out.push(other);
278                    }
279                    None => {
280                        return Err(BridgeError::InvalidInput(
281                            "XFCC value ends with dangling backslash".into(),
282                        ))
283                    }
284                }
285            } else if c == '"' {
286                return Err(BridgeError::InvalidInput(
287                    "XFCC value contains unescaped quote".into(),
288                ));
289            } else {
290                out.push(c);
291            }
292        }
293        Ok(out)
294    } else if raw.contains('"') {
295        Err(BridgeError::InvalidInput(
296            "XFCC value has mismatched quotes".into(),
297        ))
298    } else {
299        Ok(raw.to_string())
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Istio AuthN parser
305// ---------------------------------------------------------------------------
306
307/// Parse an Istio principal from one of:
308///
309/// * a base64-encoded protobuf surfaced via `x-istio-attributes`, with
310///   a single string field for `source.principal`, or
311/// * a JWT (`xxx.yyy.zzz`) whose payload contains a `sub` claim that
312///   is a `spiffe://` URI, optionally with `iss` set to an Istio-style
313///   issuer.
314///
315/// Returns an [`IstioPrincipal`] with the SPIFFE id and the namespace
316/// extracted from the SPIFFE path (`/ns/<namespace>/sa/<sa>`).
317pub fn parse_istio_attributes(header: &str) -> Result<IstioPrincipal, BridgeError> {
318    let header = header.trim();
319    if header.is_empty() {
320        return Err(BridgeError::InvalidInput("empty Istio header".into()));
321    }
322    // JWT shape: three base64url segments separated by dots. We never
323    // verify the signature here — that's the OAuth bridge's job — but
324    // we do require an Istio-shaped issuer to reject random tokens.
325    if let Some(p) = try_parse_istio_jwt(header)? {
326        return Ok(p);
327    }
328    // Otherwise treat the value as a base64(-url) encoded protobuf
329    // whose only field of interest is `source.principal` (string).
330    let bytes = decode_base64_either(header)
331        .ok_or_else(|| BridgeError::InvalidInput("Istio header is not base64 or a JWT".into()))?;
332    let principal = decode_istio_protobuf_principal(&bytes)?;
333    spiffe_to_principal(&principal)
334}
335
336/// Attempt to interpret the header as a JWT. Returns `Ok(None)` if the
337/// header is clearly not a JWT (e.g. doesn't have three dot-separated
338/// segments) so the caller can fall through to the protobuf path.
339fn try_parse_istio_jwt(header: &str) -> Result<Option<IstioPrincipal>, BridgeError> {
340    let header = header.strip_prefix("Bearer ").unwrap_or(header).trim();
341    let parts: Vec<&str> = header.split('.').collect();
342    if parts.len() != 3 {
343        return Ok(None);
344    }
345    // Each segment must be valid base64url.
346    let payload_bytes = match URL_SAFE_NO_PAD.decode(parts[1].as_bytes()) {
347        Ok(v) => v,
348        Err(_) => return Ok(None),
349    };
350    let payload: Value = match serde_json::from_slice(&payload_bytes) {
351        Ok(v) => v,
352        Err(_) => return Ok(None),
353    };
354    // We require an Istio-shaped issuer; the canonical Istio CA
355    // issuer is `https://kubernetes.default.svc.cluster.local` or
356    // `istio-ca`. Anything else is rejected to avoid happily
357    // accepting third-party JWTs as Istio principals.
358    let issuer = payload
359        .get("iss")
360        .and_then(Value::as_str)
361        .unwrap_or_default();
362    if !is_istio_issuer(issuer) {
363        return Err(BridgeError::Rejected(format!(
364            "Istio JWT has non-Istio issuer: {}",
365            issuer
366        )));
367    }
368    // Istio surfaces the SPIFFE id either as `sub` or in a `spiffe`
369    // claim.
370    let spiffe = payload
371        .get("sub")
372        .and_then(Value::as_str)
373        .or_else(|| payload.get("spiffe").and_then(Value::as_str))
374        .ok_or_else(|| BridgeError::InvalidInput("Istio JWT missing sub/spiffe claim".into()))?;
375    Ok(Some(spiffe_to_principal(spiffe)?))
376}
377
378fn is_istio_issuer(iss: &str) -> bool {
379    // Common Istio CA / SDS issuer values.
380    matches!(
381        iss,
382        "https://kubernetes.default.svc.cluster.local"
383            | "kubernetes/serviceaccount"
384            | "istio-ca"
385            | "istiod.istio-system.svc"
386    ) || iss.starts_with("https://kubernetes.default.svc")
387        || iss.starts_with("istiod.")
388}
389
390/// Hand-decode the protobuf message Istio puts in `x-istio-attributes`.
391/// We don't pull in `prost`; the wire format we care about is one
392/// length-delimited string field whose number we treat as
393/// "the only string in the message". The full Mixer `Attributes`
394/// proto is more complex, but every Istio mesh in the wild surfaces
395/// `source.principal` as a top-level string.
396fn decode_istio_protobuf_principal(bytes: &[u8]) -> Result<String, BridgeError> {
397    let mut i = 0;
398    let mut best: Option<String> = None;
399    while i < bytes.len() {
400        let (tag, n) = read_varint(&bytes[i..])
401            .ok_or_else(|| BridgeError::InvalidInput("Istio proto: bad varint tag".into()))?;
402        i += n;
403        let wire = (tag & 0x7) as u8;
404        match wire {
405            0 => {
406                // varint payload — skip
407                let (_, n) = read_varint(&bytes[i..])
408                    .ok_or_else(|| BridgeError::InvalidInput("Istio proto: bad varint".into()))?;
409                i += n;
410            }
411            1 => {
412                // 64-bit fixed
413                if bytes.len() < i + 8 {
414                    return Err(BridgeError::InvalidInput(
415                        "Istio proto: truncated fixed64".into(),
416                    ));
417                }
418                i += 8;
419            }
420            2 => {
421                // length-delimited
422                let (len, n) = read_varint(&bytes[i..]).ok_or_else(|| {
423                    BridgeError::InvalidInput("Istio proto: bad length-delim varint".into())
424                })?;
425                i += n;
426                let len = len as usize;
427                if bytes.len() < i + len {
428                    return Err(BridgeError::InvalidInput(
429                        "Istio proto: truncated length-delim".into(),
430                    ));
431                }
432                let payload = &bytes[i..i + len];
433                i += len;
434                // Heuristic: a SPIFFE principal always starts with
435                // `spiffe://`. Pick the first such string we see.
436                if let Ok(s) = std::str::from_utf8(payload) {
437                    if s.starts_with("spiffe://") && best.is_none() {
438                        best = Some(s.to_string());
439                    }
440                }
441            }
442            5 => {
443                if bytes.len() < i + 4 {
444                    return Err(BridgeError::InvalidInput(
445                        "Istio proto: truncated fixed32".into(),
446                    ));
447                }
448                i += 4;
449            }
450            other => {
451                return Err(BridgeError::InvalidInput(format!(
452                    "Istio proto: unknown wire type {}",
453                    other
454                )));
455            }
456        }
457    }
458    best.ok_or_else(|| {
459        BridgeError::InvalidInput("Istio proto: no spiffe:// principal field present".into())
460    })
461}
462
463fn read_varint(bytes: &[u8]) -> Option<(u64, usize)> {
464    let mut result: u64 = 0;
465    let mut shift = 0u32;
466    for (i, b) in bytes.iter().enumerate() {
467        if i >= 10 {
468            return None;
469        }
470        result |= ((b & 0x7f) as u64) << shift;
471        if b & 0x80 == 0 {
472            return Some((result, i + 1));
473        }
474        shift += 7;
475    }
476    None
477}
478
479fn decode_base64_either(s: &str) -> Option<Vec<u8>> {
480    if let Ok(v) = STANDARD.decode(s.as_bytes()) {
481        return Some(v);
482    }
483    URL_SAFE_NO_PAD.decode(s.as_bytes()).ok()
484}
485
486fn spiffe_to_principal(spiffe: &str) -> Result<IstioPrincipal, BridgeError> {
487    let parsed: ParsedSpiffeId = parse_spiffe_id(spiffe)?;
488    // Istio path is `/ns/<ns>/sa/<sa>`; `path` here has no leading `/`.
489    let segments: Vec<&str> = parsed.path.split('/').collect();
490    let mut namespace = String::new();
491    let mut i = 0;
492    while i + 1 < segments.len() {
493        if segments[i] == "ns" {
494            namespace = segments[i + 1].to_string();
495            break;
496        }
497        i += 1;
498    }
499    if namespace.is_empty() {
500        return Err(BridgeError::InvalidInput(format!(
501            "Istio SPIFFE id has no /ns/<namespace>/ segment: {}",
502            spiffe
503        )));
504    }
505    Ok(IstioPrincipal {
506        spiffe_id: spiffe.to_string(),
507        namespace,
508    })
509}
510
511// ---------------------------------------------------------------------------
512// Linkerd parser
513// ---------------------------------------------------------------------------
514
515/// Parse a Linkerd `l5d-client-id` header. Modern Linkerd emits a
516/// SPIFFE SVID URI; older deployments emit the legacy
517/// `<sa>.<ns>.serviceaccount.identity.<cluster>.cluster.local`
518/// form. Both are accepted.
519pub fn parse_linkerd_client_id(header: &str) -> Result<LinkerdClient, BridgeError> {
520    let header = header.trim();
521    if header.is_empty() {
522        return Err(BridgeError::InvalidInput(
523            "empty l5d-client-id header".into(),
524        ));
525    }
526    if header.starts_with("spiffe://") {
527        // Validate via the SPIFFE bridge.
528        parse_spiffe_id(header)?;
529        return Ok(LinkerdClient {
530            spiffe_id: header.to_string(),
531        });
532    }
533    if header.contains("://") {
534        return Err(BridgeError::InvalidInput(format!(
535            "l5d-client-id has non-spiffe scheme: {}",
536            header
537        )));
538    }
539    // Legacy form: convert to a synthetic SPIFFE id so downstream
540    // consumers can treat all Linkerd identities uniformly.
541    let suffix = ".serviceaccount.identity.";
542    let idx = header.find(suffix).ok_or_else(|| {
543        BridgeError::InvalidInput(format!(
544            "l5d-client-id has no `.serviceaccount.identity.` segment: {}",
545            header
546        ))
547    })?;
548    let pre = &header[..idx];
549    let post = &header[idx + suffix.len()..];
550    let cluster = post.strip_suffix(".cluster.local").ok_or_else(|| {
551        BridgeError::InvalidInput(format!(
552            "l5d-client-id missing `.cluster.local` suffix: {}",
553            header
554        ))
555    })?;
556    let dot = pre.find('.').ok_or_else(|| {
557        BridgeError::InvalidInput(format!("l5d-client-id missing `<sa>.<ns>`: {}", header))
558    })?;
559    let sa = &pre[..dot];
560    let ns = &pre[dot + 1..];
561    if sa.is_empty() || ns.is_empty() || cluster.is_empty() {
562        return Err(BridgeError::InvalidInput(format!(
563            "l5d-client-id has empty sa/ns/cluster: {}",
564            header
565        )));
566    }
567    let synthetic = format!("spiffe://{}/ns/{}/sa/{}", cluster, ns, sa);
568    parse_spiffe_id(&synthetic)?;
569    Ok(LinkerdClient {
570        spiffe_id: synthetic,
571    })
572}
573
574// ---------------------------------------------------------------------------
575// Proof-event helpers
576// ---------------------------------------------------------------------------
577
578/// Build the canonical `bridge.service_mesh.envoy.accepted` stub.
579pub fn envoy_accepted_event(entry: &XfccEntry) -> ProofEventStub {
580    ProofEventStub {
581        event_type: "bridge.service_mesh.envoy.accepted".into(),
582        payload: serde_json::json!({
583            "uri": entry.uri,
584            "by": entry.by,
585            "hash": entry.hash,
586            "subject": entry.subject,
587        }),
588    }
589}
590
591/// Build the canonical `bridge.service_mesh.istio.accepted` stub.
592pub fn istio_accepted_event(p: &IstioPrincipal) -> ProofEventStub {
593    ProofEventStub {
594        event_type: "bridge.service_mesh.istio.accepted".into(),
595        payload: serde_json::json!({
596            "spiffe_id": p.spiffe_id,
597            "namespace": p.namespace,
598        }),
599    }
600}
601
602/// Build the canonical `bridge.service_mesh.linkerd.accepted` stub.
603pub fn linkerd_accepted_event(c: &LinkerdClient) -> ProofEventStub {
604    ProofEventStub {
605        event_type: "bridge.service_mesh.linkerd.accepted".into(),
606        payload: serde_json::json!({ "spiffe_id": c.spiffe_id }),
607    }
608}
609
610// ---------------------------------------------------------------------------
611// High-level bridge object — kept compatible with sprint-5 callers.
612// ---------------------------------------------------------------------------
613
614impl ServiceMeshBridge {
615    pub fn new(cfg: ServiceMeshBridgeConfig) -> Self {
616        ServiceMeshBridge { cfg }
617    }
618
619    /// Project a parsed XFCC entry into a TrustForge identity. Inputs
620    /// should already be the output of [`parse_xfcc`]; this method is
621    /// retained for the existing sprint-5 callers that build entries
622    /// by hand.
623    pub fn accept_envoy(&self, entry: &XfccEntry) -> Result<ActorIdentity, BridgeError> {
624        let uri = entry.uri.as_deref().ok_or_else(|| {
625            BridgeError::InvalidInput("XFCC entry needs URI in this Rust path".into())
626        })?;
627        if !uri.starts_with("spiffe://") {
628            return Err(BridgeError::Rejected(
629                "Rust XFCC bridge only accepts spiffe:// URIs".into(),
630            ));
631        }
632        let actor = spiffe_to_actor_id(uri)?;
633        Ok(self.identity_from(actor, entry.by.clone()))
634    }
635
636    pub fn accept_istio(&self, spiffe_id: &str) -> Result<ActorIdentity, BridgeError> {
637        if !spiffe_id.starts_with("spiffe://") {
638            return Err(BridgeError::InvalidInput(
639                "Istio context.spiffe_id must be a spiffe:// URI".into(),
640            ));
641        }
642        let actor = spiffe_to_actor_id(spiffe_id)?;
643        Ok(self.identity_from(actor, Some("istio".into())))
644    }
645
646    pub fn accept_linkerd(&self, client_id: &str) -> Result<ActorIdentity, BridgeError> {
647        // Accept either the legacy `…serviceaccount.identity…` form
648        // (kept for the sprint-5 fixture) or the modern SPIFFE shape.
649        if client_id.starts_with("spiffe://") {
650            let actor = spiffe_to_actor_id(client_id)?;
651            return Ok(self.identity_from(actor, Some("linkerd".into())));
652        }
653        let suffix = ".serviceaccount.identity.";
654        let idx = client_id.find(suffix).ok_or_else(|| {
655            BridgeError::InvalidInput(format!("not a linkerd client_id: {}", client_id))
656        })?;
657        let pre = &client_id[..idx];
658        let post = &client_id[idx + suffix.len()..];
659        let cluster_local = post.strip_suffix(".cluster.local").ok_or_else(|| {
660            BridgeError::InvalidInput(format!("not a linkerd client_id: {}", client_id))
661        })?;
662        let dot = pre.find('.').ok_or_else(|| {
663            BridgeError::InvalidInput(format!("not a linkerd client_id: {}", client_id))
664        })?;
665        let sa = &pre[..dot];
666        let ns = &pre[dot + 1..];
667        let actor = format!("tf:actor:service:{}/{}/{}", cluster_local, ns, sa);
668        Ok(self.identity_from(actor, Some("linkerd".into())))
669    }
670
671    fn identity_from(&self, actor: String, federation: Option<String>) -> ActorIdentity {
672        ActorIdentity {
673            identity_version: ActorIdentity_IdentityVersion::V1,
674            actor_id: actor,
675            actor_type: ActorType::Service,
676            instance_id: None,
677            public_keys: vec![PublicKey {
678                key_id: "service-mesh".into(),
679                algorithm: "ed25519".into(),
680                public_key: "AA==".into(),
681                purpose: PublicKey_Purpose::Signing,
682                valid_from: None,
683                valid_until: None,
684            }],
685            trust_levels: vec![TrustLevel::T3],
686            authority_roots: vec![AuthorityRoot {
687                kind: AuthorityRoot_Kind::Federation,
688                id: federation.unwrap_or_else(|| "service-mesh".into()),
689            }],
690            attestations: None,
691            valid_from: now_iso8601(),
692            valid_until: None,
693            revocation_ref: None,
694            signature: None,
695        }
696    }
697}
698
699impl Bridge for ServiceMeshBridge {
700    fn bridge_id(&self) -> &str {
701        &self.cfg.bridge_id
702    }
703    fn kind(&self) -> BridgeKind {
704        BridgeKind::ServiceMesh
705    }
706    fn trust_domain(&self) -> &str {
707        &self.cfg.trust_domain
708    }
709}
710
711fn now_iso8601() -> String {
712    let secs = std::time::SystemTime::now()
713        .duration_since(std::time::UNIX_EPOCH)
714        .unwrap_or_default()
715        .as_secs() as i64;
716    let (y, m, d, h, mi, s) = secs_to_ymdhms(secs);
717    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, h, mi, s)
718}
719
720fn secs_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
721    let days = secs.div_euclid(86_400);
722    let time = secs.rem_euclid(86_400);
723    let hour = (time / 3600) as u32;
724    let minute = ((time % 3600) / 60) as u32;
725    let second = (time % 60) as u32;
726    let z = days + 719_468;
727    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
728    let doe = (z - era * 146_097) as u64;
729    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
730    let y = yoe as i64 + era * 400;
731    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
732    let mp = (5 * doy + 2) / 153;
733    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
734    let m = if mp < 10 {
735        (mp + 3) as u32
736    } else {
737        (mp - 9) as u32
738    };
739    let year = if m <= 2 { y + 1 } else { y };
740    (year as i32, m, d, hour, minute, second)
741}