Skip to main content

toolkit_security/
shared_secret.rs

1//! Shared-secret platform-plane authenticator (dev / single-node profiles).
2//!
3//! [`SharedSecretInternalAuthenticator`] is a dependency-light concrete
4//! [`InternalAuthenticator`] that validates an inbound
5//! `X-ToolKit-Internal-Token` by comparing it, in constant time, against a
6//! single pre-shared secret. On success it resolves the caller to
7//! [`PlatformIdentity::Shared`] with a configured label.
8//!
9//! It exists so the platform plane can be exercised **end-to-end without
10//! Kubernetes** (no `TokenReview` API, no projected service-account volume) —
11//! useful for local demos, single-node deployments, and tests. It is **not** a
12//! substitute for the K8s `TokenReview` validator in a real multi-tenant
13//! cluster: a single shared secret has none of `TokenReview`'s per-workload
14//! identity, rotation, or revocation properties.
15//!
16//! ```rust
17//! use secrecy::SecretString;
18//! use toolkit_security::{InternalAuthenticator, SharedSecretInternalAuthenticator};
19//!
20//! # async fn demo() {
21//! let auth = SharedSecretInternalAuthenticator::try_new(
22//!     SecretString::from("dev-internal-token"),
23//!     "toolkit-host".to_owned(),
24//! )
25//! .expect("a non-empty secret");
26//! assert!(auth.authenticate("dev-internal-token").await.is_ok());
27//! assert!(auth.authenticate("wrong").await.is_err());
28//! # }
29//! ```
30
31use secrecy::{ExposeSecret, SecretString};
32
33use crate::internal_auth::{InternalAuthNError, InternalAuthenticator, PlatformIdentity};
34
35/// A platform-plane authenticator backed by a single pre-shared secret.
36///
37/// See the [module docs](self) for when this is (and is not) appropriate.
38#[derive(Clone)]
39pub struct SharedSecretInternalAuthenticator {
40    secret: SecretString,
41    peer_name: String,
42}
43
44impl std::fmt::Debug for SharedSecretInternalAuthenticator {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        // Never render the secret.
47        f.debug_struct("SharedSecretInternalAuthenticator")
48            .field("peer_name", &self.peer_name)
49            .finish_non_exhaustive()
50    }
51}
52
53/// The placeholder written in place of a secret when a config is serialized.
54///
55/// Rejected as a secret: a config dumped by `--print-config` would otherwise
56/// round-trip into a working one whose platform credential is this literal —
57/// a value published in the repository.
58pub const REDACTED_PLACEHOLDER: &str = "<redacted>";
59
60/// Why a shared secret was refused.
61#[derive(Debug, thiserror::Error, PartialEq, Eq)]
62pub enum InvalidSharedSecret {
63    /// The secret was empty, which would authenticate an empty token.
64    #[error("shared secret must not be empty")]
65    Empty,
66    /// The secret is the redaction placeholder, i.e. a dumped config was fed
67    /// back in as a real one.
68    #[error(
69        "shared secret is the literal `{REDACTED_PLACEHOLDER}` placeholder, which a serialized \
70         config writes in place of the real secret"
71    )]
72    RedactedPlaceholder,
73}
74
75impl SharedSecretInternalAuthenticator {
76    /// Build an authenticator that accepts exactly `secret` and resolves valid
77    /// callers to [`PlatformIdentity::Shared`] with the label `peer_name`.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`InvalidSharedSecret`] if the secret is empty or is the
82    /// [`REDACTED_PLACEHOLDER`]. An empty secret is the dangerous one: the
83    /// comparison in [`InternalAuthenticator::authenticate`] is over bytes, and
84    /// an empty secret matches an empty token — so anyone sending the internal
85    /// header with no value would authenticate as `peer_name`.
86    pub fn try_new(secret: SecretString, peer_name: String) -> Result<Self, InvalidSharedSecret> {
87        match secret.expose_secret() {
88            "" => Err(InvalidSharedSecret::Empty),
89            REDACTED_PLACEHOLDER => Err(InvalidSharedSecret::RedactedPlaceholder),
90            _ => Ok(Self { secret, peer_name }),
91        }
92    }
93}
94
95impl InternalAuthenticator for SharedSecretInternalAuthenticator {
96    async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
97        if constant_time_eq(token.as_bytes(), self.secret.expose_secret().as_bytes()) {
98            Ok(PlatformIdentity::Shared {
99                name: self.peer_name.clone(),
100            })
101        } else {
102            // A rejected platform-plane credential left no trace at all, so a
103            // peer configured with the wrong secret looked identical to one
104            // that was never configured. The peer name is the configured label,
105            // not anything the caller supplied, and the token never appears.
106            tracing::warn!(
107                peer_name = %self.peer_name,
108                "platform-plane authentication rejected: shared secret did not match"
109            );
110            Err(InternalAuthNError::InvalidToken)
111        }
112    }
113}
114
115/// Compare two byte slices without short-circuiting on the first differing
116/// byte, so validation time does not leak the position of a mismatch.
117///
118/// The length comparison is not itself constant-time; a pre-shared secret's
119/// length is not sensitive here.
120fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
121    if a.len() != b.len() {
122        return false;
123    }
124    let mut diff = 0u8;
125    for (x, y) in a.iter().zip(b.iter()) {
126        diff |= x ^ y;
127    }
128    diff == 0
129}
130
131#[cfg(test)]
132#[cfg_attr(coverage_nightly, coverage(off))]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn an_empty_secret_is_refused() {
138        // The comparison is over bytes, so an empty secret matches an empty
139        // token: anyone sending the internal header with no value would
140        // authenticate. Refusing at construction is the only place to catch it.
141        assert_eq!(
142            SharedSecretInternalAuthenticator::try_new(SecretString::from(""), "peer".to_owned())
143                .unwrap_err(),
144            InvalidSharedSecret::Empty
145        );
146    }
147
148    #[test]
149    fn the_redaction_placeholder_is_refused() {
150        // A config dumped by `--print-config` writes this in place of the
151        // secret and deserializes cleanly, so without this the platform plane
152        // would come up accepting a literal published in the repository.
153        assert_eq!(
154            SharedSecretInternalAuthenticator::try_new(
155                SecretString::from(REDACTED_PLACEHOLDER),
156                "peer".to_owned()
157            )
158            .unwrap_err(),
159            InvalidSharedSecret::RedactedPlaceholder
160        );
161    }
162
163    #[tokio::test]
164    async fn an_empty_token_is_rejected_by_a_real_secret() {
165        let rejected = auth().authenticate("").await;
166        assert!(matches!(rejected, Err(InternalAuthNError::InvalidToken)));
167    }
168
169    fn auth() -> SharedSecretInternalAuthenticator {
170        SharedSecretInternalAuthenticator::try_new(SecretString::from("s3cr3t"), "peer".to_owned())
171            .expect("a non-empty secret")
172    }
173
174    #[tokio::test]
175    async fn accepts_matching_secret_and_resolves_shared_identity() {
176        let identity = auth().authenticate("s3cr3t").await.expect("valid secret");
177        assert_eq!(
178            identity,
179            PlatformIdentity::Shared {
180                name: "peer".to_owned()
181            }
182        );
183        assert_eq!(identity.peer_name(), "peer");
184    }
185
186    #[tokio::test]
187    async fn rejects_wrong_secret() {
188        let err = auth().authenticate("nope").await.unwrap_err();
189        assert!(matches!(err, InternalAuthNError::InvalidToken));
190    }
191
192    #[tokio::test]
193    async fn rejects_empty_and_prefix_tokens() {
194        assert!(auth().authenticate("").await.is_err());
195        assert!(auth().authenticate("s3cr3").await.is_err());
196        assert!(auth().authenticate("s3cr3tt").await.is_err());
197    }
198
199    #[test]
200    fn constant_time_eq_matches_std_eq() {
201        assert!(constant_time_eq(b"abc", b"abc"));
202        assert!(!constant_time_eq(b"abc", b"abd"));
203        assert!(!constant_time_eq(b"abc", b"ab"));
204        assert!(constant_time_eq(b"", b""));
205    }
206
207    #[test]
208    fn debug_never_leaks_secret() {
209        let rendered = format!("{:?}", auth());
210        assert!(rendered.contains("peer"));
211        assert!(!rendered.contains("s3cr3t"));
212    }
213}