Skip to main content

polyc_rpc_client/
edge_credentials.rs

1//! Per-edge transport and provenance credentials for [`crate::AgentDialer`].
2//!
3//! A turn-ingress dialer holds [`EdgeCredentials`], rides the bearer token as
4//! an `Authorization` header on every call, and signs a fresh
5//! [`AssertedAttribution`] envelope onto each turn's `AgentStart`
6//! (`docs/reference/edge-authentication.md` — see `crates/crypto/src/edge_identity.rs`
7//! for the envelope's canonical-bytes/signature contract).
8
9use polyc_crypto::{Signer, sensitive::Sensitive};
10use polyc_proto::proto::polychrome::agent::v1::AssertedAttribution;
11use polyc_proto::proto::polychrome::approval::v1::{ApprovalResponseRequest, AssertedApproval};
12use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;
13
14/// Errors building [`EdgeCredentials`] from an edge's configuration.
15#[derive(Debug, thiserror::Error)]
16pub enum CredentialError {
17    /// `signing_key_hex` was not valid hex (odd length or a non-hex byte).
18    #[error("edge signing key is not valid hex")]
19    InvalidHex,
20    /// The decoded signing key was not a valid ed25519 private key (wrong
21    /// length or otherwise malformed).
22    #[error("edge signing key: {0}")]
23    InvalidSigningKey(#[from] polyc_crypto::SignerError),
24}
25
26/// One edge's transport bearer plus its ed25519 envelope-signing key.
27///
28/// Built from the edge's own configuration (`POLYCHROME_EDGE_ID`,
29/// `POLYCHROME_EDGE_BEARER_KEY`, `POLYCHROME_EDGE_SIGNING_KEY_HEX` — see the
30/// edge-auth redesign doc) via [`EdgeCredentials::from_parts`], then passed to
31/// [`crate::AgentDialer::with_credentials`]. Held by the dialer behind an
32/// `Arc`, so cloning a dialer never re-derives or duplicates the key.
33pub struct EdgeCredentials {
34    edge_id: String,
35    bearer: Sensitive<String>,
36    signer: Signer,
37}
38
39impl EdgeCredentials {
40    /// Build credentials from an edge's id, transport bearer token, and
41    /// hex-encoded 32-byte ed25519 private key.
42    ///
43    /// # Errors
44    ///
45    /// Returns [`CredentialError::InvalidHex`] if `signing_key_hex` isn't
46    /// valid hex, or [`CredentialError::InvalidSigningKey`] if the decoded
47    /// bytes aren't a valid ed25519 private key.
48    pub fn from_parts(
49        edge_id: String,
50        bearer: String,
51        signing_key_hex: &str,
52    ) -> Result<Self, CredentialError> {
53        let key_bytes =
54            polyc_crypto::hex::decode(signing_key_hex).ok_or(CredentialError::InvalidHex)?;
55        let signer = Signer::from_key_bytes(&key_bytes)?;
56        Ok(Self {
57            edge_id,
58            bearer: Sensitive::new(bearer),
59            signer,
60        })
61    }
62
63    /// This edge's registry id — carried as [`AssertedAttribution::edge_id`]
64    /// on every envelope this credential signs.
65    #[must_use]
66    pub fn edge_id(&self) -> &str {
67        &self.edge_id
68    }
69
70    /// The transport bearer token, ridden as `Authorization: Bearer <token>`
71    /// on every call a dialer built with these credentials makes.
72    #[must_use]
73    pub fn bearer(&self) -> &str {
74        self.bearer.expose()
75    }
76
77    /// Sign `a` in place: fills its `signature_hex` over the envelope's
78    /// canonical bytes (mirrors
79    /// [`polyc_crypto::edge_identity::sign_edge_assertion_into`], which this
80    /// delegates to).
81    pub fn sign_assertion(&self, a: &mut AssertedAttribution) {
82        polyc_crypto::edge_identity::sign_edge_assertion_into(&self.signer, a);
83    }
84
85    /// Assert `responder` as the human who resolved `request`, and sign it in
86    /// place (`#1553`).
87    ///
88    /// Puts an [`AssertedApproval`] naming this edge and `responder` on
89    /// `request`, then signs the whole request under the approval-assertion
90    /// domain — the same ed25519 identity key [`Self::sign_assertion`] uses
91    /// for turn dispatch, since an edge has exactly one identity to assert
92    /// with.
93    ///
94    /// `request` must already be FINAL. The signature covers every other
95    /// field of it — the decision, an approve's modified arguments, the
96    /// `resolve_token` — so a field set after this call invalidates the
97    /// signature it was meant to be covered by. See
98    /// [`polyc_crypto::approval_assertion`], which this delegates to.
99    pub fn attach_approval_assertion(
100        &self,
101        request: &mut ApprovalResponseRequest,
102        responder: ExternalIdentity,
103    ) {
104        polyc_crypto::approval_assertion::attach_approval_assertion(
105            &self.signer,
106            request,
107            AssertedApproval {
108                edge_id: self.edge_id.clone(),
109                responder: buffa::MessageField::some(responder),
110                // Overwritten by the signature this call mints; the canonical
111                // bytes clear it before encoding either way.
112                signature_hex: String::new(),
113                __buffa_unknown_fields: buffa::UnknownFields::default(),
114            },
115        );
116    }
117}
118
119/// Errors from [`edge_credentials_from_env_or_fail`].
120#[derive(Debug, thiserror::Error)]
121pub enum EdgeCredentialsError {
122    /// `edge_id`/`bearer`/`signing_key_hex` are not all configured AND
123    /// `agent_addr` is not a loopback host — the edge-auth review's fail-fast
124    /// rule (`#1514`): an edge with no credentials must not silently dial a
125    /// remote, possibly-enforcing control plane unauthenticated.
126    #[error(
127        "edge credentials are unconfigured (POLYCHROME_EDGE_ID / POLYCHROME_EDGE_BEARER_KEY / \
128         POLYCHROME_EDGE_SIGNING_KEY_HEX) and the control plane at {agent_addr:?} is not a \
129         loopback address — refusing to start rather than dial it unauthenticated"
130    )]
131    Unconfigured {
132        /// The (non-loopback) control-plane address this edge was pointed at.
133        agent_addr: String,
134    },
135    /// The three values were present but malformed (bad hex / bad ed25519 key).
136    #[error(transparent)]
137    Invalid(#[from] CredentialError),
138}
139
140/// Build this edge's [`EdgeCredentials`] from its own configuration.
141///
142/// The single credential-or-fail decision every edge's startup previously
143/// hand-duplicated (`#1514` review, part (a)/(c)): a loopback-scoped soft
144/// fallback, and a fail-fast hard stop otherwise.
145///
146/// - All three of `edge_id`/`bearer`/`signing_key_hex` present (non-empty):
147///   returns `Ok(Some(creds))`.
148/// - Unconfigured AND `agent_addr` names a loopback host (`127.0.0.0/8`,
149///   `::1`, `localhost`): logs the one warning below and returns
150///   `Ok(None)` — safe only because a loopback control plane in this
151///   deployment shape is either non-enforcing local dev or was started by
152///   the same process tree, never a remote endpoint an unauthenticated dial
153///   could leak a turn to.
154/// - Unconfigured AND `agent_addr` is NOT loopback: returns
155///   [`EdgeCredentialsError::Unconfigured`] — fails the edge's startup
156///   rather than dialing a remote control plane with no identity to assert.
157///
158/// The caller still decides which dialers to build from the returned
159/// `Option`: `Some` selects each service's `with_bearer`/`with_credentials`
160/// constructor, `None` selects its unauthenticated `new`.
161///
162/// # Errors
163///
164/// Returns [`EdgeCredentialsError::Unconfigured`] per the fail-fast rule
165/// above, or [`EdgeCredentialsError::Invalid`] if the three values are
166/// present but `signing_key_hex` isn't valid hex / a valid ed25519 key.
167pub fn edge_credentials_from_env_or_fail(
168    agent_addr: &str,
169    edge_id: Option<&str>,
170    bearer: Option<&str>,
171    signing_key_hex: Option<&str>,
172) -> Result<Option<EdgeCredentials>, EdgeCredentialsError> {
173    // An empty value is "unset", not "configured as the empty string": a
174    // Kubernetes Secret whose key exists with a `""` placeholder (see
175    // `manifests/components/edges/*/secret.yaml`) reaches an edge as
176    // `Some("")`. Normalizing HERE — rather than asking every edge to
177    // remember the same `.filter(|s| !s.is_empty())` on all three values —
178    // is what makes the "all three present (non-empty)" contract above true
179    // for every caller, including the next edge someone adds. Without it a
180    // half-configured edge would build credentials with an empty bearer and
181    // fail at runtime with a 401, instead of failing fast at startup here.
182    let edge_id = edge_id.filter(|s| !s.is_empty());
183    let bearer = bearer.filter(|s| !s.is_empty());
184    let signing_key_hex = signing_key_hex.filter(|s| !s.is_empty());
185    if let (Some(edge_id), Some(bearer), Some(signing_key_hex)) = (edge_id, bearer, signing_key_hex)
186    {
187        let creds =
188            EdgeCredentials::from_parts(edge_id.to_owned(), bearer.to_owned(), signing_key_hex)?;
189        return Ok(Some(creds));
190    }
191    if is_loopback_addr(agent_addr) {
192        tracing::warn!(
193            "edge credentials are unconfigured (POLYCHROME_EDGE_ID / \
194             POLYCHROME_EDGE_BEARER_KEY / POLYCHROME_EDGE_SIGNING_KEY_HEX) — dialing the \
195             control plane unauthenticated; an enforcing control plane will reject these calls"
196        );
197        return Ok(None);
198    }
199    Err(EdgeCredentialsError::Unconfigured {
200        agent_addr: agent_addr.to_owned(),
201    })
202}
203
204/// Whether `addr` (`http://host:port`) names a loopback host —
205/// `127.0.0.0/8`, `::1`, or `localhost`. No DNS: an unresolvable host is
206/// treated as non-loopback (fails closed towards
207/// [`EdgeCredentialsError::Unconfigured`]).
208///
209/// Mirrors `is_loopback_addr` in `crates/cli/src/cmd/send.rs` — that copy
210/// gates a *different* decision (falling back to `dev_credentials`) that
211/// this crate must not depend on (a Container, the wrong dependency
212/// direction for a Component); the loopback predicate itself is duplicated
213/// on purpose rather than shared.
214fn is_loopback_addr(addr: &str) -> bool {
215    let Ok(uri) = addr.parse::<http::Uri>() else {
216        return false;
217    };
218    let Some(host) = uri.host() else {
219        return false;
220    };
221    host.eq_ignore_ascii_case("localhost")
222        || host
223            .parse::<std::net::IpAddr>()
224            .is_ok_and(|ip| ip.is_loopback())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn all_three_present_builds_credentials() {
233        let creds = edge_credentials_from_env_or_fail(
234            "https://control-plane.example.com",
235            Some("slack"),
236            Some("pc_slack_test-secret"),
237            Some(&polyc_crypto::hex::lower(&[7u8; 32])),
238        )
239        .expect("build succeeds")
240        .expect("credentials present");
241        assert_eq!(creds.edge_id(), "slack");
242        assert_eq!(creds.bearer(), "pc_slack_test-secret");
243    }
244
245    #[test]
246    fn unconfigured_loopback_warns_and_returns_none() {
247        let creds = edge_credentials_from_env_or_fail("http://127.0.0.1:8080", None, None, None)
248            .expect("loopback falls back rather than erroring");
249        assert!(creds.is_none());
250    }
251
252    #[test]
253    fn unconfigured_localhost_hostname_returns_none() {
254        let creds = edge_credentials_from_env_or_fail("http://localhost:8080", None, None, None)
255            .expect("localhost hostname counts as loopback");
256        assert!(creds.is_none());
257    }
258
259    #[test]
260    fn unconfigured_non_loopback_fails_fast() {
261        // `.err()` (not `.expect_err()`): `EdgeCredentials` deliberately
262        // holds no `Debug` impl (it carries a signing key), and
263        // `Result::expect_err` requires the `Ok` side (`Option<EdgeCredentials>`)
264        // to implement it.
265        let err = edge_credentials_from_env_or_fail(
266            "https://control-plane.example.com",
267            None,
268            None,
269            None,
270        )
271        .err()
272        .expect("a non-loopback unconfigured dial must fail fast");
273        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
274    }
275
276    #[test]
277    fn partially_configured_non_loopback_fails_fast() {
278        // Only `edge_id` set — still unconfigured (all three are required).
279        let err = edge_credentials_from_env_or_fail(
280            "https://control-plane.example.com",
281            Some("slack"),
282            None,
283            None,
284        )
285        .err()
286        .expect("a partial configuration must fail fast against a remote address");
287        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
288    }
289
290    #[test]
291    fn empty_strings_count_as_unconfigured_not_as_configured() {
292        // The shape a Kubernetes Secret's `""` placeholder actually delivers.
293        // Must fail fast against a remote exactly like `None` would, rather
294        // than building credentials with an empty bearer that a control plane
295        // would 401 at runtime.
296        let err = edge_credentials_from_env_or_fail(
297            "https://control-plane.example.com",
298            Some(""),
299            Some(""),
300            Some(""),
301        )
302        .err()
303        .expect("empty values are unconfigured, so a remote dial must fail fast");
304        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
305    }
306
307    #[test]
308    fn an_empty_bearer_alone_is_still_unconfigured() {
309        // Partial emptiness is the dangerous shape: `edge_id` set, bearer
310        // blank. Must NOT build credentials that dial with `Bearer `.
311        let err = edge_credentials_from_env_or_fail(
312            "https://control-plane.example.com",
313            Some("slack"),
314            Some(""),
315            Some(&polyc_crypto::hex::lower(&[7u8; 32])),
316        )
317        .err()
318        .expect("a blank bearer must fail fast, not dial unauthenticated");
319        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
320    }
321
322    #[test]
323    fn invalid_signing_key_surfaces_as_invalid() {
324        let err = edge_credentials_from_env_or_fail(
325            "http://127.0.0.1:8080",
326            Some("slack"),
327            Some("pc_slack_test-secret"),
328            Some("not-hex"),
329        )
330        .err()
331        .expect("malformed signing key must not silently fall back");
332        assert!(matches!(err, EdgeCredentialsError::Invalid(_)));
333    }
334}