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