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}