polyc-rpc-client 2026.8.0

Thin connectrpc client over the generated AgentServiceClient, shared by the CLI and Slack receiver.
Documentation
//! Per-edge transport and provenance credentials for [`crate::AgentDialer`].
//!
//! Additive on top of the unauthenticated [`crate::AgentDialer::new`] path
//! (the internal loopback self-dial and the CLI's default dial keep working
//! unchanged): an edge that holds an [`EdgeCredentials`] dials via
//! [`crate::AgentDialer::with_credentials`] instead, which rides the bearer
//! token as an `Authorization` header on every call and signs a fresh
//! [`AssertedAttribution`] envelope onto each turn's `AgentStart`
//! (`docs/design` edge-auth redesign — see `crates/crypto/src/edge_identity.rs`
//! for the envelope's canonical-bytes/signature contract).

use polyc_crypto::{Signer, sensitive::Sensitive};
use polyc_proto::proto::polychrome::agent::v1::AssertedAttribution;
use polyc_proto::proto::polychrome::approval::v1::{ApprovalResponseRequest, AssertedApproval};
use polyc_proto::proto::polychrome::persona::v1::ExternalIdentity;

/// Errors building [`EdgeCredentials`] from an edge's configuration.
#[derive(Debug, thiserror::Error)]
pub enum CredentialError {
    /// `signing_key_hex` was not valid hex (odd length or a non-hex byte).
    #[error("edge signing key is not valid hex")]
    InvalidHex,
    /// The decoded signing key was not a valid ed25519 private key (wrong
    /// length or otherwise malformed).
    #[error("edge signing key: {0}")]
    InvalidSigningKey(#[from] polyc_crypto::SignerError),
}

/// One edge's transport bearer plus its ed25519 envelope-signing key.
///
/// Built from the edge's own configuration (`POLYCHROME_EDGE_ID`,
/// `POLYCHROME_EDGE_BEARER_KEY`, `POLYCHROME_EDGE_SIGNING_KEY_HEX` — see the
/// edge-auth redesign doc) via [`EdgeCredentials::from_parts`], then passed to
/// [`crate::AgentDialer::with_credentials`]. Held by the dialer behind an
/// `Arc`, so cloning a dialer never re-derives or duplicates the key.
pub struct EdgeCredentials {
    edge_id: String,
    bearer: Sensitive<String>,
    signer: Signer,
}

impl EdgeCredentials {
    /// Build credentials from an edge's id, transport bearer token, and
    /// hex-encoded 32-byte ed25519 private key.
    ///
    /// # Errors
    ///
    /// Returns [`CredentialError::InvalidHex`] if `signing_key_hex` isn't
    /// valid hex, or [`CredentialError::InvalidSigningKey`] if the decoded
    /// bytes aren't a valid ed25519 private key.
    pub fn from_parts(
        edge_id: String,
        bearer: String,
        signing_key_hex: &str,
    ) -> Result<Self, CredentialError> {
        let key_bytes =
            polyc_crypto::hex::decode(signing_key_hex).ok_or(CredentialError::InvalidHex)?;
        let signer = Signer::from_key_bytes(&key_bytes)?;
        Ok(Self {
            edge_id,
            bearer: Sensitive::new(bearer),
            signer,
        })
    }

    /// This edge's registry id — carried as [`AssertedAttribution::edge_id`]
    /// on every envelope this credential signs.
    #[must_use]
    pub fn edge_id(&self) -> &str {
        &self.edge_id
    }

    /// The transport bearer token, ridden as `Authorization: Bearer <token>`
    /// on every call a dialer built with these credentials makes.
    #[must_use]
    pub fn bearer(&self) -> &str {
        self.bearer.expose()
    }

    /// Sign `a` in place: fills its `signature_hex` over the envelope's
    /// canonical bytes (mirrors
    /// [`polyc_crypto::edge_identity::sign_edge_assertion_into`], which this
    /// delegates to).
    pub fn sign_assertion(&self, a: &mut AssertedAttribution) {
        polyc_crypto::edge_identity::sign_edge_assertion_into(&self.signer, a);
    }

    /// Assert `responder` as the human who resolved `request`, and sign it in
    /// place (`#1553`).
    ///
    /// Puts an [`AssertedApproval`] naming this edge and `responder` on
    /// `request`, then signs the whole request under the approval-assertion
    /// domain — the same ed25519 identity key [`Self::sign_assertion`] uses
    /// for turn dispatch, since an edge has exactly one identity to assert
    /// with.
    ///
    /// `request` must already be FINAL. The signature covers every other
    /// field of it — the decision, an approve's modified arguments, the
    /// `resolve_token` — so a field set after this call invalidates the
    /// signature it was meant to be covered by. See
    /// [`polyc_crypto::approval_assertion`], which this delegates to.
    pub fn attach_approval_assertion(
        &self,
        request: &mut ApprovalResponseRequest,
        responder: ExternalIdentity,
    ) {
        polyc_crypto::approval_assertion::attach_approval_assertion(
            &self.signer,
            request,
            AssertedApproval {
                edge_id: self.edge_id.clone(),
                responder: buffa::MessageField::some(responder),
                // Overwritten by the signature this call mints; the canonical
                // bytes clear it before encoding either way.
                signature_hex: String::new(),
                __buffa_unknown_fields: buffa::UnknownFields::default(),
            },
        );
    }
}

/// Errors from [`edge_credentials_from_env_or_fail`].
#[derive(Debug, thiserror::Error)]
pub enum EdgeCredentialsError {
    /// `edge_id`/`bearer`/`signing_key_hex` are not all configured AND
    /// `agent_addr` is not a loopback host — the edge-auth review's fail-fast
    /// rule (`#1514`): an edge with no credentials must not silently dial a
    /// remote, possibly-enforcing control plane unauthenticated.
    #[error(
        "edge credentials are unconfigured (POLYCHROME_EDGE_ID / POLYCHROME_EDGE_BEARER_KEY / \
         POLYCHROME_EDGE_SIGNING_KEY_HEX) and the control plane at {agent_addr:?} is not a \
         loopback address — refusing to start rather than dial it unauthenticated"
    )]
    Unconfigured {
        /// The (non-loopback) control-plane address this edge was pointed at.
        agent_addr: String,
    },
    /// The three values were present but malformed (bad hex / bad ed25519 key).
    #[error(transparent)]
    Invalid(#[from] CredentialError),
}

/// Build this edge's [`EdgeCredentials`] from its own configuration.
///
/// The single credential-or-fail decision every edge's startup previously
/// hand-duplicated (`#1514` review, part (a)/(c)): a loopback-scoped soft
/// fallback, and a fail-fast hard stop otherwise.
///
/// - All three of `edge_id`/`bearer`/`signing_key_hex` present (non-empty):
///   returns `Ok(Some(creds))`.
/// - Unconfigured AND `agent_addr` names a loopback host (`127.0.0.0/8`,
///   `::1`, `localhost`): logs the one warning below and returns
///   `Ok(None)` — safe only because a loopback control plane in this
///   deployment shape is either non-enforcing local dev or was started by
///   the same process tree, never a remote endpoint an unauthenticated dial
///   could leak a turn to.
/// - Unconfigured AND `agent_addr` is NOT loopback: returns
///   [`EdgeCredentialsError::Unconfigured`] — fails the edge's startup
///   rather than dialing a remote control plane with no identity to assert.
///
/// The caller still decides which dialers to build from the returned
/// `Option`: `Some` selects each service's `with_bearer`/`with_credentials`
/// constructor, `None` selects its unauthenticated `new`.
///
/// # Errors
///
/// Returns [`EdgeCredentialsError::Unconfigured`] per the fail-fast rule
/// above, or [`EdgeCredentialsError::Invalid`] if the three values are
/// present but `signing_key_hex` isn't valid hex / a valid ed25519 key.
pub fn edge_credentials_from_env_or_fail(
    agent_addr: &str,
    edge_id: Option<&str>,
    bearer: Option<&str>,
    signing_key_hex: Option<&str>,
) -> Result<Option<EdgeCredentials>, EdgeCredentialsError> {
    // An empty value is "unset", not "configured as the empty string": a
    // Kubernetes Secret whose key exists with a `""` placeholder (see
    // `manifests/components/edges/*/secret.yaml`) reaches an edge as
    // `Some("")`. Normalizing HERE — rather than asking every edge to
    // remember the same `.filter(|s| !s.is_empty())` on all three values —
    // is what makes the "all three present (non-empty)" contract above true
    // for every caller, including the next edge someone adds. Without it a
    // half-configured edge would build credentials with an empty bearer and
    // fail at runtime with a 401, instead of failing fast at startup here.
    let edge_id = edge_id.filter(|s| !s.is_empty());
    let bearer = bearer.filter(|s| !s.is_empty());
    let signing_key_hex = signing_key_hex.filter(|s| !s.is_empty());
    if let (Some(edge_id), Some(bearer), Some(signing_key_hex)) = (edge_id, bearer, signing_key_hex)
    {
        let creds =
            EdgeCredentials::from_parts(edge_id.to_owned(), bearer.to_owned(), signing_key_hex)?;
        return Ok(Some(creds));
    }
    if is_loopback_addr(agent_addr) {
        tracing::warn!(
            "edge credentials are unconfigured (POLYCHROME_EDGE_ID / \
             POLYCHROME_EDGE_BEARER_KEY / POLYCHROME_EDGE_SIGNING_KEY_HEX) — dialing the \
             control plane unauthenticated; an enforcing control plane will reject these calls"
        );
        return Ok(None);
    }
    Err(EdgeCredentialsError::Unconfigured {
        agent_addr: agent_addr.to_owned(),
    })
}

/// Whether `addr` (`http://host:port`) names a loopback host —
/// `127.0.0.0/8`, `::1`, or `localhost`. No DNS: an unresolvable host is
/// treated as non-loopback (fails closed towards
/// [`EdgeCredentialsError::Unconfigured`]).
///
/// Mirrors `is_loopback_addr` in `crates/cli/src/cmd/send.rs` — that copy
/// gates a *different* decision (falling back to `dev_credentials`) that
/// this crate must not depend on (a Container, the wrong dependency
/// direction for a Component); the loopback predicate itself is duplicated
/// on purpose rather than shared.
fn is_loopback_addr(addr: &str) -> bool {
    let Ok(uri) = addr.parse::<http::Uri>() else {
        return false;
    };
    let Some(host) = uri.host() else {
        return false;
    };
    host.eq_ignore_ascii_case("localhost")
        || host
            .parse::<std::net::IpAddr>()
            .is_ok_and(|ip| ip.is_loopback())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn all_three_present_builds_credentials() {
        let creds = edge_credentials_from_env_or_fail(
            "https://control-plane.example.com",
            Some("slack"),
            Some("pc_slack_test-secret"),
            Some(&polyc_crypto::hex::lower(&[7u8; 32])),
        )
        .expect("build succeeds")
        .expect("credentials present");
        assert_eq!(creds.edge_id(), "slack");
        assert_eq!(creds.bearer(), "pc_slack_test-secret");
    }

    #[test]
    fn unconfigured_loopback_warns_and_returns_none() {
        let creds = edge_credentials_from_env_or_fail("http://127.0.0.1:8080", None, None, None)
            .expect("loopback falls back rather than erroring");
        assert!(creds.is_none());
    }

    #[test]
    fn unconfigured_localhost_hostname_returns_none() {
        let creds = edge_credentials_from_env_or_fail("http://localhost:8080", None, None, None)
            .expect("localhost hostname counts as loopback");
        assert!(creds.is_none());
    }

    #[test]
    fn unconfigured_non_loopback_fails_fast() {
        // `.err()` (not `.expect_err()`): `EdgeCredentials` deliberately
        // holds no `Debug` impl (it carries a signing key), and
        // `Result::expect_err` requires the `Ok` side (`Option<EdgeCredentials>`)
        // to implement it.
        let err = edge_credentials_from_env_or_fail(
            "https://control-plane.example.com",
            None,
            None,
            None,
        )
        .err()
        .expect("a non-loopback unconfigured dial must fail fast");
        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
    }

    #[test]
    fn partially_configured_non_loopback_fails_fast() {
        // Only `edge_id` set — still unconfigured (all three are required).
        let err = edge_credentials_from_env_or_fail(
            "https://control-plane.example.com",
            Some("slack"),
            None,
            None,
        )
        .err()
        .expect("a partial configuration must fail fast against a remote address");
        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
    }

    #[test]
    fn empty_strings_count_as_unconfigured_not_as_configured() {
        // The shape a Kubernetes Secret's `""` placeholder actually delivers.
        // Must fail fast against a remote exactly like `None` would, rather
        // than building credentials with an empty bearer that a control plane
        // would 401 at runtime.
        let err = edge_credentials_from_env_or_fail(
            "https://control-plane.example.com",
            Some(""),
            Some(""),
            Some(""),
        )
        .err()
        .expect("empty values are unconfigured, so a remote dial must fail fast");
        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
    }

    #[test]
    fn an_empty_bearer_alone_is_still_unconfigured() {
        // Partial emptiness is the dangerous shape: `edge_id` set, bearer
        // blank. Must NOT build credentials that dial with `Bearer `.
        let err = edge_credentials_from_env_or_fail(
            "https://control-plane.example.com",
            Some("slack"),
            Some(""),
            Some(&polyc_crypto::hex::lower(&[7u8; 32])),
        )
        .err()
        .expect("a blank bearer must fail fast, not dial unauthenticated");
        assert!(matches!(err, EdgeCredentialsError::Unconfigured { .. }));
    }

    #[test]
    fn invalid_signing_key_surfaces_as_invalid() {
        let err = edge_credentials_from_env_or_fail(
            "http://127.0.0.1:8080",
            Some("slack"),
            Some("pc_slack_test-secret"),
            Some("not-hex"),
        )
        .err()
        .expect("malformed signing key must not silently fall back");
        assert!(matches!(err, EdgeCredentialsError::Invalid(_)));
    }
}